#!/bin/sh # # get-n8n.sh — one-script setup for a self-hosted n8n instance (docker compose). # # Usage: # curl -fsSL https://get.n8n.io | sh # curl -fsSL https://get.n8n.io | sh -s -- --upgrade # # Prefer to inspect before running? # curl -fsSL https://get.n8n.io -o get-n8n.sh && less get-n8n.sh && sh get-n8n.sh # # Source: https://github.com/n8n-io/n8n/blob/master/docker/get-n8n.sh set -eu SCRIPT_VERSION="1.1.0" # The version to install is derived from the latest stable GitHub release in # resolve_n8n_version(); this fallback only applies when that lookup fails. FALLBACK_N8N_VERSION="2.32.0" N8N_DIR="${N8N_DIR:-./n8n}" N8N_PORT=5678 SOURCE_URL="https://github.com/n8n-io/n8n/blob/master/docker/get-n8n.sh" # The stack definition lives next to this script in the repo and is downloaded # at install time. A plain filesystem path also works (used by the test # harness to install from a working copy). COMPOSE_SOURCE="${N8N_COMPOSE_URL:-https://raw.githubusercontent.com/n8n-io/n8n/master/docker/get-n8n-compose.yml}" COMPOSE_HISTORY_URL="https://github.com/n8n-io/n8n/commits/master/docker/get-n8n-compose.yml" DOCS_HOSTING_URL="https://docs.n8n.io/hosting/" UPGRADE=0 NO_START=0 REQUESTED_VERSION="" say() { printf '%s\n' "$*"; } ok() { printf '\342\234\223 %s\n' "$*"; } fail() { printf 'Error: %s\n' "$*" >&2 exit 1 } usage() { cat <&2 fail "unknown option: $1" ;; esac shift done } check_deps() { command -v docker >/dev/null 2>&1 || fail "Docker is not installed. Install it first: Linux / WSL: curl -fsSL https://get.docker.com | sh then: sudo usermod -aG docker \$USER && re-open your shell macOS: https://docs.docker.com/desktop/setup/install/mac-install/ Windows: https://docs.docker.com/desktop/setup/install/windows-install/ (enable your distro under Settings > Resources > WSL integration) Podman, Colima and other Docker-compatible engines work too: install the 'docker' CLI with the compose plugin and point DOCKER_HOST at their socket." docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 plugin is not available. ('docker compose version' failed — the legacy 'docker-compose' binary is not supported.) See https://docs.docker.com/compose/install/" docker info >/dev/null 2>&1 || fail "The Docker daemon is not running (or DOCKER_HOST points at a dead socket). Start Docker (e.g. open Docker Desktop, or 'sudo systemctl start docker') and re-run. Podman/Colima users: make sure the machine/socket is running, e.g. 'podman machine start' or 'colima start', and DOCKER_HOST points at it." ok "Docker found ($(docker --version | awk '{print $3}' | tr -d ','))" ok "Docker Compose found ($(docker compose version --short 2>/dev/null))" } # Prints the content of a URL (or plain file path) to stdout. fetch() { case "$1" in *://*) if command -v curl >/dev/null 2>&1; then curl -fsSL --max-time 10 "$1" 2>/dev/null elif command -v wget >/dev/null 2>&1; then wget -qO- -T 10 "$1" 2>/dev/null else return 2 fi ;; *) cat "$1" 2>/dev/null ;; esac } # Returns 0 if an HTTP GET against $1 gets any response at all. http_get() { if command -v curl >/dev/null 2>&1; then curl -sf -o /dev/null --max-time 3 "$1" elif command -v wget >/dev/null 2>&1; then wget -q -O /dev/null -T 3 "$1" 2>/dev/null else return 2 fi } port_in_use() { # In use only when something actually answers. Refusals AND timeouts count # as free: Windows firewalls/WSL drop instead of refusing, which made the # old "anything but refused" check a false positive. --noproxy keeps a # corporate proxy from answering on 127.0.0.1's behalf. Ambiguous cases # fall through to the container healthcheck, which surfaces real conflicts. if command -v curl >/dev/null 2>&1; then rc=0 curl -s -o /dev/null --noproxy '*' --max-time 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$? # rc 0 = HTTP response; 52/56 = connection accepted but no/broken reply. [ "$rc" -eq 0 ] || [ "$rc" -eq 52 ] || [ "$rc" -eq 56 ] elif command -v wget >/dev/null 2>&1; then rc=0 wget -q -O /dev/null --no-proxy -T 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$? # rc 0 = OK response; 8 = server issued an error response. [ "$rc" -eq 0 ] || [ "$rc" -eq 8 ] else return 1 fi } gen_secret() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 24 else od -An -tx1 -N24 /dev/urandom | tr -d ' \n' fi } # Exactly three dot-separated numeric components (e.g. 2.32.0). valid_n8n_version() { case "$1" in *[!0-9.]* | *.*.*.* | .* | *. | *..*) return 1 ;; [0-9]*.[0-9]*.[0-9]*) return 0 ;; *) return 1 ;; esac } # Latest stable n8n version from GitHub releases; FALLBACK_N8N_VERSION covers # offline/API failures. Installs stay pinned in .env — never a floating tag, # which would silently upgrade (and run DB migrations) on any container recreate. resolve_n8n_version() { releases_url="https://api.github.com/repos/n8n-io/n8n/releases/latest" v="$(fetch "$releases_url" | sed -n 's/.*"tag_name": *"n8n@\([0-9][0-9.]*\)".*/\1/p' | head -n1)" if valid_n8n_version "$v"; then printf '%s\n' "$v" else printf '%s\n' "$FALLBACK_N8N_VERSION" fi } write_env() { sandbox_api_key="$(gen_secret)" runner_key="$(gen_secret)" registration_token="$(gen_secret)" cat >"${N8N_DIR}/.env" <"${N8N_DIR}/compose.yml.tmp" || ! grep -q '^services:' "${N8N_DIR}/compose.yml.tmp"; then rm -f "${N8N_DIR}/compose.yml.tmp" fail "could not download the stack definition from ${COMPOSE_SOURCE} Check your network connection and re-run." fi mv "${N8N_DIR}/compose.yml.tmp" "${N8N_DIR}/compose.yml" } # Existing installs keep their compose.yml untouched, so when the published # stack definition has moved on, say so instead of silently rewriting it. # Best-effort: stays quiet if offline or if the user removed the version line. check_compose_freshness() { [ -f "${N8N_DIR}/compose.yml" ] || return 0 installed="$(compose_version <"${N8N_DIR}/compose.yml")" [ -n "$installed" ] || return 0 latest="$(fetch "$COMPOSE_SOURCE" | compose_version)" [ -n "$latest" ] && [ "$latest" != "$installed" ] || return 0 say "" say "Note: the n8n stack definition is now v${latest}; this install was generated" say "from v${installed}. get-n8n.sh never rewrites an existing compose.yml." say "To review what changed: ${COMPOSE_HISTORY_URL}" } write_searxng_settings() { # The stock image serves HTML only; n8n's web search needs the JSON API. cat >"${N8N_DIR}/searxng-settings.yml" <<'EOF' # Generated by get-n8n.sh. SearXNG configuration for the n8n AI assistant. use_default_settings: true search: formats: - html - json EOF } compose() { docker compose -f "${N8N_DIR}/compose.yml" "$@" } # For compose commands that pull images: Docker Hub's anonymous pull rate # limit is a common first-run failure, so turn it into recovery advice # instead of a raw error dump. compose_checked() { log="$(mktemp)" if compose "$@" >"$log" 2>&1; then cat "$log" rm -f "$log" return 0 fi cat "$log" >&2 if grep -qiE 'toomanyrequests|rate ?limit' "$log"; then rm -f "$log" fail "Docker Hub pull rate limit reached — your configuration in ${N8N_DIR} is unaffected. Wait about an hour, then start n8n with: docker compose -f ${N8N_DIR}/compose.yml up -d Or 'docker login' with a Docker Hub account to raise the limit and re-run." fi rm -f "$log" fail "starting n8n failed — check 'docker compose -f ${N8N_DIR}/compose.yml logs'." } do_upgrade() { if [ ! -f "${N8N_DIR}/.env" ] || [ ! -f "${N8N_DIR}/compose.yml" ]; then fail "no existing install found in ${N8N_DIR} — run without --upgrade to install." fi target="${REQUESTED_VERSION:-$(resolve_n8n_version)}" current="$(sed -n 's/^N8N_VERSION=//p' "${N8N_DIR}/.env")" if [ -n "$current" ]; then # .env is user state: --upgrade edits exactly this one line, nothing else. sed "s/^N8N_VERSION=.*/N8N_VERSION=${target}/" "${N8N_DIR}/.env" >"${N8N_DIR}/.env.tmp" mv "${N8N_DIR}/.env.tmp" "${N8N_DIR}/.env" chmod 600 "${N8N_DIR}/.env" else printf 'N8N_VERSION=%s\n' "$target" >>"${N8N_DIR}/.env" fi ok "n8n version: ${current:-unset} -> ${target}" check_compose_freshness if [ "$NO_START" -eq 1 ]; then say "" say "Not restarting (--no-start). To apply the upgrade:" say " docker compose -f ${N8N_DIR}/compose.yml pull && docker compose -f ${N8N_DIR}/compose.yml up -d" exit 0 fi compose_checked pull -q compose_checked up -d --quiet-pull ok "Restarted with n8n ${target}" } wait_for_n8n() { printf 'Waiting for n8n to become ready (first boot pulls images and runs migrations) ' waited=0 while [ "$waited" -lt 180 ]; do if http_get "http://127.0.0.1:${N8N_PORT}/healthz"; then printf '\n' return 0 fi printf '.' sleep 3 waited=$((waited + 3)) done printf '\n' return 1 } print_summary() { cat <