IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

update.sh

Open raw
#!/usr/bin/env bash
set -Eeuo pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'

APP_NAME="home-server"
INSTALL_DIR="${HOMEIO_INSTALL_DIR:-/opt/home-server}"
ENV_FILE="${HOMEIO_ENV_FILE:-${INSTALL_DIR}/.env}"
SERVICE_NAME="${HOMEIO_SERVICE_NAME:-home-server}"
DBUS_SERVICE_NAME="${HOMEIO_DBUS_SERVICE_NAME:-home-server-dbus}"
UPLOAD_SERVICE_NAME="${HOMEIO_UPLOAD_SERVICE_NAME:-home-server-upload}"
APP_PORT="${HOMEIO_APP_PORT:-${HOMEIO_PORT:-12026}}"
PUBLIC_PORT="${HOMEIO_PUBLIC_PORT:-80}"
NGINX_SITE_NAME="${HOMEIO_NGINX_SITE_NAME:-home-server}"
REPO_URL="${HOMEIO_REPO_URL:-https://github.com/doctor-io/homeio.git}"
REPO_BRANCH="${HOMEIO_REPO_BRANCH:-main}"

# The tarball install path is gone; a pinned version is now a git tag.
if [[ -n "${HOMEIO_RELEASE_TAG:-}" || -n "${HOMEIO_RELEASE_TARBALL_URL:-}" ]]; then
	echo "[!] HOMEIO_RELEASE_TAG and HOMEIO_RELEASE_TARBALL_URL are no longer supported." >&2
	echo "[!] Homeio installs with git. To pin a version, set HOMEIO_REPO_BRANCH to its tag, for example HOMEIO_REPO_BRANCH=v1.10.0." >&2
	exit 1
fi
GO_VERSION="${GO_VERSION:-1.23.4}"

# SHA-256 of drizzle/0000_slippery_black_queen.sql — used to seed the migration journal
# for legacy installs that were bootstrapped with drizzle push (no __drizzle_migrations table).
BASELINE_MIGRATION_HASH="e10db77d840d8dc1f42a13ee9de57615a2fb7c46d9525e0d1e7a7f42dee72eaf"
BASELINE_MIGRATION_TS="1776413023965"

HOMEIO_CREATE_BACKUP="${HOMEIO_CREATE_BACKUP:-true}"
HOMEIO_BACKUP_ROOT="${HOMEIO_BACKUP_ROOT:-/var/backups/home-server/releases}"
HOMEIO_HEALTHCHECK_URL="${HOMEIO_HEALTHCHECK_URL:-http://127.0.0.1:${APP_PORT}/api/health}"
HOMEIO_HEALTHCHECK_RETRIES="${HOMEIO_HEALTHCHECK_RETRIES:-60}"
HOMEIO_HEALTHCHECK_DELAY_SEC="${HOMEIO_HEALTHCHECK_DELAY_SEC:-3}"

SERVICE_UNIT="${SERVICE_NAME}"
if [[ "${SERVICE_UNIT}" != *.service ]]; then
	SERVICE_UNIT="${SERVICE_UNIT}.service"
fi

DBUS_SERVICE_UNIT="${DBUS_SERVICE_NAME}"
if [[ "${DBUS_SERVICE_UNIT}" != *.service ]]; then
	DBUS_SERVICE_UNIT="${DBUS_SERVICE_UNIT}.service"
fi

UPLOAD_SERVICE_UNIT="${UPLOAD_SERVICE_NAME}"
if [[ "${UPLOAD_SERVICE_UNIT}" != *.service ]]; then
	UPLOAD_SERVICE_UNIT="${UPLOAD_SERVICE_UNIT}.service"
fi

BACKUP_DIR=""
PREVIOUS_GIT_REV=""
ROLLBACK_READY="false"
ROLLBACK_DONE="false"
PREVIOUS_LOCK_HASH=""
NEW_LOCK_HASH=""

print_status() { echo -e "${GREEN}[+]${NC} $1"; }
print_error() { echo -e "${RED}[!]${NC} $1" >&2; }
print_warn() { echo -e "${YELLOW}[*]${NC} $1"; }

command_exists() {
	command -v "$1" >/dev/null 2>&1
}

# The tag of the release GitHub marks as Latest, or nothing when it cannot be
# read (offline, rate-limited, no release yet). Never fails.
latest_release_tag() {
	local repo_path="${REPO_URL#https://github.com/}"
	repo_path="${repo_path%.git}"
	local json tag
	json="$(curl -fsSL --max-time 20 -H "Accept: application/vnd.github+json" \
		"https://api.github.com/repos/${repo_path}/releases/latest" 2>/dev/null)" || return 0
	tag="$(printf '%s\n' "${json}" | sed -n 's/^[[:space:]]*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p')"
	if [[ "${tag}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
		printf '%s\n' "${tag}"
	fi
}

# Without HOMEIO_REPO_BRANCH, install the latest published release rather than
# whatever main holds between a merge and its release. git fetches a tag the
# same way as a branch.
resolve_repo_ref() {
	[[ -n "${HOMEIO_REPO_BRANCH:-}" ]] && return 0
	local tag
	tag="$(latest_release_tag)"
	if [[ -n "${tag}" ]]; then
		REPO_BRANCH="${tag}"
		print_status "Latest release: ${tag}"
	else
		print_warn "Could not read the latest release from GitHub; using the ${REPO_BRANCH} branch."
	fi
}

require_root() {
	[[ "${EUID}" -eq 0 ]] || { print_error "Run this updater as root (for example: sudo bash update.sh)."; exit 1; }
}

hash_file() {
	local file="${1}"
	[[ -f "${file}" ]] || return 0
	sha256sum "${file}" | awk '{print $1}'
}

check_prerequisites() {
	print_status "Checking prerequisites..."
	command_exists apt-get || { print_error "apt-get is required."; exit 1; }
	command_exists systemctl || { print_error "systemd is required but systemctl is not available."; exit 1; }
	command_exists rsync || { print_error "rsync is required."; exit 1; }
	command_exists curl || { print_error "curl is required."; exit 1; }
	command_exists npm || { print_error "npm is required."; exit 1; }

	[[ -d "${INSTALL_DIR}" ]] || { print_error "Install directory not found: ${INSTALL_DIR}"; exit 1; }
	[[ -f "${ENV_FILE}" ]] || { print_error "Environment file not found: ${ENV_FILE}"; exit 1; }
}

# Debian 12+ no longer ships rsyslog, so /var/log/auth.log does not exist and
# fail2ban's default backend aborts on the sshd jail. Read the journal instead.
# Kept apart from jail.d/homeio.local, which the app rewrites from Settings.
configure_fail2ban_backend() {
	mkdir -p /etc/fail2ban/jail.d
	cat > /etc/fail2ban/jail.d/00-homeio-backend.local <<'CONF'
[DEFAULT]
backend = systemd

[sshd]
backend = systemd
CONF
}

ensure_security_dependencies() {
	print_status "Ensuring security dependencies (ufw, fail2ban)..."

	local packages=()
	command_exists ufw || packages+=("ufw")
	command_exists fail2ban-client || packages+=("fail2ban")
	dpkg-query -W -f='${Status}' python3-systemd 2>/dev/null | grep -q "install ok installed" || packages+=("python3-systemd")

	if (( ${#packages[@]} > 0 )); then
		apt-get update -qq >/dev/null
		apt-get install -y -qq "${packages[@]}" >/dev/null
	fi

	configure_fail2ban_backend
	systemctl enable --now fail2ban >/dev/null 2>&1 || true
	systemctl restart fail2ban >/dev/null 2>&1 || true
}

capture_current_state() {
	PREVIOUS_LOCK_HASH="$(hash_file "${INSTALL_DIR}/package-lock.json" || true)"
	if [[ -d "${INSTALL_DIR}/.git" ]]; then
		PREVIOUS_GIT_REV="$(git -C "${INSTALL_DIR}" rev-parse HEAD || true)"
	fi
}

create_backup() {
	if [[ "${HOMEIO_CREATE_BACKUP}" != "true" ]]; then
		print_warn "Skipping code backup (HOMEIO_CREATE_BACKUP=${HOMEIO_CREATE_BACKUP})."
		return
	fi

	local ts
	ts="$(date '+%Y%m%d-%H%M%S')"
	BACKUP_DIR="${HOMEIO_BACKUP_ROOT}/${ts}"
	mkdir -p "${BACKUP_DIR}"

	print_status "Creating backup at ${BACKUP_DIR}..."
	rsync -a \
		--delete \
		--exclude ".git" \
		--exclude "node_modules" \
		--exclude ".next" \
		"${INSTALL_DIR}/" "${BACKUP_DIR}/"
}

deploy_from_git() {
	if [[ -d "${INSTALL_DIR}/.git" ]]; then
		print_status "Updating from git (${REPO_BRANCH})..."
		git -C "${INSTALL_DIR}" fetch --depth=1 origin "${REPO_BRANCH}" --quiet
		git -C "${INSTALL_DIR}" checkout --force FETCH_HEAD --quiet
	else
		print_warn "No git repository at ${INSTALL_DIR}. Cloning fresh copy from ${REPO_URL}..."
		local tmp_dir
		tmp_dir="$(mktemp -d)"
		local clone_branch="${REPO_BRANCH:-main}"
		git clone --depth=1 --branch "${clone_branch}" --quiet "${REPO_URL}" "${tmp_dir}/repo"

		rsync -a \
			--delete \
			--exclude ".git" \
			--exclude "node_modules" \
			--exclude ".next" \
			--exclude ".env" \
			--exclude ".env.local" \
			"${tmp_dir}/repo/" "${INSTALL_DIR}/"

		rm -rf "${tmp_dir}"
		print_status "Fresh clone deployed."
	fi
}

install_dependencies_if_needed() {
	NEW_LOCK_HASH="$(hash_file "${INSTALL_DIR}/package-lock.json" || true)"

	if [[ "${PREVIOUS_LOCK_HASH}" != "${NEW_LOCK_HASH}" || ! -d "${INSTALL_DIR}/node_modules" ]]; then
		print_status "Dependency changes detected. Installing npm dependencies..."
		cd "${INSTALL_DIR}" && npm ci --silent --no-audit --no-fund 1>/dev/null
	else
		print_status "No dependency changes detected. Skipping npm ci."
	fi
}

run_database_migrations() {
	print_status "Syncing database schema..."

	if [[ ! -f "${ENV_FILE}" ]]; then
		print_error "Environment file not found: ${ENV_FILE}. Cannot sync schema."
		exit 1
	fi

	(set -a && source "${ENV_FILE}" && set +a && cd "${INSTALL_DIR}" && npm run db:init) \
		|| { print_error "Database schema sync failed. Rolling back."; exit 1; }

	print_status "Database schema synced successfully."
}

install_go() {
	local arch go_arch
	case "$(uname -m)" in
		x86_64)  arch="amd64" ;;
		aarch64) arch="arm64" ;;
		*) print_error "Unsupported architecture: $(uname -m)"; exit 1 ;;
	esac
	go_arch="${arch}"

	if command -v go >/dev/null 2>&1; then
		local current
		current="$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//')"
		if [[ "${current}" == "${GO_VERSION}" ]]; then
			print_status "Go ${GO_VERSION} already installed."
			export PATH="/usr/local/go/bin:${PATH}"
			return
		fi
		print_status "Existing Go ${current} detected; installing Go ${GO_VERSION}."
	fi

	print_status "Installing Go ${GO_VERSION}..."
	local tarball="go${GO_VERSION}.linux-${go_arch}.tar.gz"
	curl -fsSL "https://go.dev/dl/${tarball}" -o "/tmp/${tarball}"
	rm -rf /usr/local/go
	tar -C /usr/local -xzf "/tmp/${tarball}"
	rm -f "/tmp/${tarball}"

	if [[ ! -f /etc/profile.d/go.sh ]]; then
		echo 'export PATH="/usr/local/go/bin:$PATH"' > /etc/profile.d/go.sh
	fi
	export PATH="/usr/local/go/bin:${PATH}"
	print_status "Go ${GO_VERSION} installed."
}

build_upload_server() {
	print_status "Building upload server (Go)..."
	export PATH="/usr/local/go/bin:${PATH}"
	# systemd-run starts the updater in a sparse environment with no $HOME.
	# `go build` refuses to run without GOCACHE/XDG_CACHE_HOME/HOME, so set
	# sane defaults here. Keeps build_upload_server safe regardless of how
	# update.sh was invoked.
	export HOME="${HOME:-/root}"
	export GOCACHE="${GOCACHE:-${HOME}/.cache/go-build}"
	export GOPATH="${GOPATH:-${HOME}/go}"

	local src="${INSTALL_DIR}/services/upload-server"
	[[ -d "${src}" ]] || { print_warn "Upload server source not found at ${src}; skipping."; return; }

	mkdir -p "${INSTALL_DIR}/bin"
	mkdir -p "${GOCACHE}" "${GOPATH}"

	local build_log
	build_log="$(mktemp)"
	if ! (cd "${src}" && env HOME="${HOME}" GOCACHE="${GOCACHE}" GOPATH="${GOPATH}" go build -o "${INSTALL_DIR}/bin/upload-server" .) >"${build_log}" 2>&1; then
		print_error "Failed to build upload server."
		print_error "Last output:"
		tail -10 "${build_log}" >&2
		rm -f "${build_log}"
		exit 1
	fi
	rm -f "${build_log}"
	print_status "Upload server built: ${INSTALL_DIR}/bin/upload-server"
}

stop_upload_server() {
	if systemctl cat "${UPLOAD_SERVICE_UNIT}" >/dev/null 2>&1; then
		print_status "Stopping ${UPLOAD_SERVICE_NAME} service..."
		systemctl stop "${UPLOAD_SERVICE_UNIT}" >/dev/null 2>&1 || true
	fi
}

restart_upload_server_service() {
	local unit_file="/etc/systemd/system/${UPLOAD_SERVICE_UNIT}"
	local is_new=false

	[[ ! -f "${unit_file}" ]] && is_new=true

	print_status "Writing ${UPLOAD_SERVICE_NAME} unit file..."
	cat >"${unit_file}" <<EOF
[Unit]
Description=${APP_NAME} Upload Service
After=network.target

[Service]
Type=simple
User=root
EnvironmentFile=${ENV_FILE}
Environment=UPLOAD_SERVER_ADDR=/run/home-server-upload/upload.sock
# A directory of its own: the DBus helper owns /run/home-server (mode 0770), and
# sharing it meant whichever service started last set its mode, and stopping
# either one deleted the other's socket, leaving nginx unable to reach uploads.
RuntimeDirectory=home-server-upload
RuntimeDirectoryMode=0755
ExecStart=${INSTALL_DIR}/bin/upload-server
Restart=always
RestartSec=5
KillMode=process
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${UPLOAD_SERVICE_NAME}

[Install]
WantedBy=multi-user.target
EOF
	systemctl daemon-reload
	if [[ "${is_new}" == "true" ]]; then
		systemctl enable "${UPLOAD_SERVICE_UNIT}"
	fi

	if [[ ! -f "${INSTALL_DIR}/bin/upload-server" ]]; then
		print_warn "Upload server binary not found; skipping service restart."
		return
	fi

	print_status "Restarting ${UPLOAD_SERVICE_NAME} service..."
	systemctl daemon-reload
	systemctl restart "${UPLOAD_SERVICE_UNIT}"

	sleep 1
	if systemctl is-active --quiet "${UPLOAD_SERVICE_UNIT}"; then
		print_status "Upload server service restarted successfully."
	else
		print_warn "Upload server service failed to start. Check: journalctl -u ${UPLOAD_SERVICE_NAME} -n 20"
	fi
}

build_app() {
	print_status "Building Next.js application..."
	local build_log
	build_log="$(mktemp)"
	if ! (cd "${INSTALL_DIR}" && npm run build >"${build_log}" 2>&1); then
		print_error "Build failed."
		print_error "Last output:"
		tail -20 "${build_log}" >&2
		rm -f "${build_log}"
		exit 1
	fi
	rm -f "${build_log}"
}

stop_service() {
	print_status "Stopping ${SERVICE_NAME} service..."
	systemctl stop "${SERVICE_UNIT}" >/dev/null 2>&1 || true
}

ensure_service_shutdown_behavior() {
	local unit_name="${SERVICE_UNIT%.service}"
	local drop_in_dir="/etc/systemd/system/${unit_name}.service.d"
	local drop_in_file="${drop_in_dir}/10-shutdown.conf"

	print_status "Ensuring clean ${SERVICE_NAME} shutdown settings..."
	mkdir -p "${drop_in_dir}"
	cat >"${drop_in_file}" <<'EOF'
[Service]
KillMode=control-group
TimeoutStopSec=30s
EOF

	systemctl daemon-reload
}

start_service() {
	print_status "Starting ${SERVICE_NAME} service..."
	systemctl daemon-reload
	systemctl start "${SERVICE_UNIT}"
}

restart_dbus_helper_service() {
	if ! systemctl cat "${DBUS_SERVICE_UNIT}" >/dev/null 2>&1; then
		print_warn "DBus helper unit ${DBUS_SERVICE_UNIT} not found; skipping helper restart."
		return
	fi

	print_status "Restarting DBus helper service..."
	systemctl daemon-reload
	systemctl enable --now "${DBUS_SERVICE_UNIT}"
	systemctl restart "${DBUS_SERVICE_UNIT}"
}

# Debian ships nginx with 768 connections per worker. Each open Homeio desktop
# holds three live streams, and each stream uses two connections (visitor side
# and Homeio side), so the default caps a server at a few hundred desktops.
# Only these two limits are raised; the rest of nginx.conf is left alone.
tune_nginx_limits() {
	local main_conf="/etc/nginx/nginx.conf"
	local worker_connections=4096
	local worker_rlimit_nofile=16384
	[[ -f "${main_conf}" ]] || return 0

	local current raise_connections=false add_rlimit=false
	current="$(sed -nE 's/^\s*worker_connections\s+([0-9]+);.*/\1/p' "${main_conf}" | head -n1)"
	if [[ -n "${current}" ]] && (( current < worker_connections )); then
		raise_connections=true
	fi
	if ! grep -qE '^\s*worker_rlimit_nofile\s' "${main_conf}"; then
		add_rlimit=true
	fi
	if [[ "${raise_connections}" == false && "${add_rlimit}" == false ]]; then
		return 0
	fi

	cp "${main_conf}" "${main_conf}.homeio-bak"
	if [[ "${raise_connections}" == true ]]; then
		sed -i -E "s/^(\s*worker_connections\s+)[0-9]+;/\1${worker_connections};/" "${main_conf}"
	fi
	if [[ "${add_rlimit}" == true ]]; then
		sed -i -E "/^\s*worker_processes\s/a worker_rlimit_nofile ${worker_rlimit_nofile};" "${main_conf}"
	fi

	if ! nginx -t >/dev/null 2>&1; then
		print_warn "nginx rejected the raised connection limits; keeping its previous settings."
		mv "${main_conf}.homeio-bak" "${main_conf}"
		return 0
	fi
	rm -f "${main_conf}.homeio-bak"
	print_status "nginx now accepts ${worker_connections} connections per worker."
}

configure_reverse_proxy() {
	if ! command_exists nginx; then
		print_warn "nginx is not installed; skipping reverse proxy update."
		return
	fi

	local nginx_conf="/etc/nginx/sites-available/${NGINX_SITE_NAME}.conf"
	local nginx_enabled="/etc/nginx/sites-enabled/${NGINX_SITE_NAME}.conf"
	local maintenance_root="/var/lib/homeio-maintenance"
	local maintenance_file="${maintenance_root}/__homeio_unavailable.html"

	print_status "Configuring nginx reverse proxy on :${PUBLIC_PORT} -> 127.0.0.1:${APP_PORT}..."

	mkdir -p "${maintenance_root}"
	cat >"${maintenance_file}" <<'EOF'
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta http-equiv="cache-control" content="no-store" />
    <title>Homeio is restarting</title>
    <style>
      :root {
        color-scheme: dark;
        --bg: #07111c;
        --panel: rgba(10, 24, 39, 0.82);
        --border: rgba(148, 163, 184, 0.18);
        --text: #f8fafc;
        --muted: #94a3b8;
        --accent: #38bdf8;
        --warn: #f59e0b;
        --error: #ef4444;
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        background:
          radial-gradient(circle at 20% 20%, rgba(56, 189, 248, 0.16), transparent 38%),
          radial-gradient(circle at 80% 70%, rgba(14, 165, 233, 0.12), transparent 42%),
          var(--bg);
        color: var(--text);
        font-family: Inter, ui-sans-serif, system-ui, sans-serif;
      }
      .panel {
        width: min(92vw, 32rem);
        padding: 2rem;
        border-radius: 1.5rem;
        border: 1px solid var(--border);
        background: var(--panel);
        backdrop-filter: blur(14px);
        box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
        text-align: center;
      }
      .spinner {
        width: 3rem;
        height: 3rem;
        margin: 0 auto 1rem;
        border-radius: 999px;
        border: 3px solid rgba(148, 163, 184, 0.24);
        border-top-color: var(--accent);
        animation: spin 0.9s linear infinite;
      }
      .spinner.warn { border-top-color: var(--warn); }
      h1 {
        margin: 0;
        font-size: 1.35rem;
      }
      p {
        margin: 0.75rem 0 0;
        line-height: 1.6;
        color: var(--muted);
      }
      .status {
        margin-top: 1rem;
        font-size: 0.85rem;
        color: var(--muted);
      }
      .log-hint {
        display: none;
        margin-top: 1.25rem;
        padding: 0.75rem 1rem;
        border-radius: 0.75rem;
        background: rgba(245, 158, 11, 0.1);
        border: 1px solid rgba(245, 158, 11, 0.25);
        font-size: 0.8rem;
        color: var(--warn);
        text-align: left;
        line-height: 1.6;
      }
      .log-hint code {
        display: block;
        margin-top: 0.4rem;
        font-family: monospace;
        word-break: break-all;
        color: #fde68a;
      }
      @keyframes spin { to { transform: rotate(360deg); } }
    </style>
  </head>
  <body>
    <main class="panel">
      <div class="spinner" id="spinner" aria-hidden="true"></div>
      <h1>Homeio is restarting</h1>
      <p>
        The update is being applied. This page will automatically reload when
        <code>/api/health</code> is available again.
      </p>
      <div class="status" id="status">Waiting for Homeio...</div>
      <div class="log-hint" id="log-hint">
        The update is taking longer than expected — it may still be building, or an error may have occurred.
        Check the update log for details:
        <code>sudo tail -f /var/log/homeio-self-update.log</code>
        To manually restart the service:
        <code>sudo systemctl restart home-server</code>
      </div>
    </main>
    <script>
      const statusEl = document.getElementById("status");
      const spinnerEl = document.getElementById("spinner");
      const logHintEl = document.getElementById("log-hint");
      const startedAt = Date.now();
      const WARN_AFTER_MS = 5 * 60 * 1000; // 5 minutes

      async function checkHealth() {
        try {
          const response = await fetch("/api/health", { cache: "no-store" });
          if (response.ok) {
            statusEl.textContent = "Homeio is back. Reloading...";
            window.location.reload();
            return;
          }
        } catch (_) {
          // Ignore while the backend is still down.
        }

        const elapsed = Date.now() - startedAt;
        if (elapsed >= WARN_AFTER_MS) {
          statusEl.textContent = "Still waiting — the update may have failed.";
          spinnerEl.classList.add("warn");
          logHintEl.style.display = "block";
        } else {
          const mins = Math.floor(elapsed / 60000);
          const secs = Math.floor((elapsed % 60000) / 1000);
          statusEl.textContent = `Waiting for Homeio… (${mins}m ${secs}s)`;
        }

        window.setTimeout(checkHealth, 2000);
      }
      window.setTimeout(checkHealth, 1500);
    </script>
  </body>
</html>
EOF

	cat >"${nginx_conf}" <<EOF
upstream homeio_backend {
    server 127.0.0.1:${APP_PORT};
    # Idle connections kept per nginx worker for reuse. With too few, every
    # request past the pool opened a fresh connection to Homeio and, at a few
    # hundred desktops, Homeio's accept queue overflowed into 502s.
    keepalive 128;
    # Homeio keeps idle connections open longer than this, so nginx is always
    # the side that closes them (see lib/server/http/keep-alive.ts).
    keepalive_timeout 60s;
}

server {
    listen ${PUBLIC_PORT};
    listen [::]:${PUBLIC_PORT};
    server_name _;

    # Do not advertise the nginx version in responses or error pages.
    server_tokens off;

    client_max_body_size 10G;
    client_body_timeout 7200s;
    client_header_timeout 300s;
    client_body_buffer_size 128k;

    proxy_read_timeout 7200s;
    proxy_send_timeout 7200s;
    proxy_connect_timeout 300s;

    proxy_intercept_errors on;
    error_page 502 503 504 /__homeio_unavailable.html;

    location = /__homeio_unavailable.html {
        root ${maintenance_root};
        default_type text/html;
        add_header Cache-Control "no-store, no-cache, must-revalidate" always;
    }

    # Route file uploads directly to the Go upload server, bypassing Next.js.
    # Next.js still decides who may upload: nginx asks it first, so the session
    # is checked against the database and demo mode blocks uploads like every
    # other write (app/api/v1/files/upload/authorize).
    location = /api/v1/files/upload {
        auth_request /__homeio_upload_authorize;
        error_page 401 = @homeio_upload_unauthorized;
        error_page 403 = @homeio_upload_forbidden;

        proxy_pass http://unix:/run/home-server-upload/upload.sock:/upload;
        proxy_http_version 1.1;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    }

    location = /__homeio_upload_authorize {
        internal;
        proxy_pass http://homeio_backend/api/v1/files/upload/authorize;
        proxy_http_version 1.1;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header Content-Type "";
        proxy_set_header Connection "";
        proxy_set_header Host \$host;
    }

    location @homeio_upload_unauthorized {
        default_type application/json;
        return 401 '{"error":"Unauthorized"}';
    }

    location @homeio_upload_forbidden {
        default_type application/json;
        return 403 '{"error":"This action is not available in demo mode."}';
    }

    # The Terminal window's shell runs over a WebSocket. Everything else
    # drops the Connection header to keep upstream connections alive, which
    # would strip the upgrade, so this path forwards it. \$http_host keeps
    # the port: the server checks it against the page's Origin.
    location = /api/terminal {
        proxy_pass http://homeio_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$http_host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_buffering off;
    }

    location / {
        proxy_pass http://homeio_backend;
        proxy_http_version 1.1;

        proxy_request_buffering off;
        proxy_buffering off;

        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_set_header Connection "";

        gzip off;
    }
}
EOF

	ln -sf "${nginx_conf}" "${nginx_enabled}"
	rm -f /etc/nginx/sites-enabled/default >/dev/null 2>&1 || true

	tune_nginx_limits
	nginx -t
	systemctl enable --now nginx
	systemctl reload nginx
}

healthcheck() {
	local attempts=0
	while (( attempts < HOMEIO_HEALTHCHECK_RETRIES )); do
		if curl -fsS --max-time 5 "${HOMEIO_HEALTHCHECK_URL}" >/dev/null; then
			return 0
		fi
		attempts=$((attempts + 1))
		sleep "${HOMEIO_HEALTHCHECK_DELAY_SEC}"
	done

	return 1
}

rollback_release() {
	trap - ERR
	ROLLBACK_DONE="true"

	print_error "Update failed! Attempting rollback..."
	stop_upload_server
	stop_service

	if [[ -n "${BACKUP_DIR}" && -d "${BACKUP_DIR}" ]]; then
		print_status "Restoring backup from ${BACKUP_DIR}..."
		rsync -a --delete "${BACKUP_DIR}/" "${INSTALL_DIR}/"
	elif [[ -n "${PREVIOUS_GIT_REV}" && -d "${INSTALL_DIR}/.git" ]]; then
		print_status "Restoring previous git revision ${PREVIOUS_GIT_REV}..."
		git -C "${INSTALL_DIR}" checkout --force "${PREVIOUS_GIT_REV}" --quiet
	else
		print_error "No rollback source available."; exit 1;
	fi

	print_status "Rebuilding application after rollback..."
	cd "${INSTALL_DIR}" && npm ci --no-audit --no-fund
	cd "${INSTALL_DIR}" && npm run build
	build_upload_server || true
	start_service
	restart_upload_server_service || true

	if healthcheck; then
		print_status "Rollback succeeded."
	else
		print_error "Rollback failed health check. Manual intervention required."; exit 1;
	fi
}

on_error() {
	local line_no="${1}"
	local exit_code="${2}"
	if [[ "${ROLLBACK_READY}" == "true" && "${ROLLBACK_DONE}" != "true" ]]; then
		print_error "Update failed at line ${line_no} (exit ${exit_code})."
		rollback_release || true
	fi
	exit "${exit_code}"
}

print_summary() {
	local host primary_ip local_url network_url
	host="$(hostnamectl --static 2>/dev/null || hostname)"
	primary_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"

	if [[ "${PUBLIC_PORT}" == "80" ]]; then
		local_url="http://${host}.local"
	else
		local_url="http://${host}.local:${PUBLIC_PORT}"
	fi

	if [[ -n "${primary_ip}" ]]; then
		if [[ "${PUBLIC_PORT}" == "80" ]]; then
			network_url="http://${primary_ip}"
		else
			network_url="http://${primary_ip}:${PUBLIC_PORT}"
		fi
	fi

	echo ""
	echo -e "${GREEN}╭────────────────────────────────────────────────────╮${NC}"
	echo -e "${GREEN}│         Update Complete!                          │${NC}"
	echo -e "${GREEN}├────────────────────────────────────────────────────┤${NC}"
	echo -e "${GREEN}│${NC}  ${APP_NAME} has been updated successfully!      ${GREEN}│${NC}"
	echo -e "${GREEN}│${NC}                                                  ${GREEN}│${NC}"
	printf "%b\n" "${GREEN}│${NC}  ${BLUE}*${NC} Local:      ${BLUE}${local_url}${NC}"
	if [[ -n "${network_url}" ]]; then
		printf "%b\n" "${GREEN}│${NC}  ${BLUE}*${NC} Network:    ${BLUE}${network_url}${NC}"
	fi
	echo -e "${GREEN}│${NC}                                                  ${GREEN}│${NC}"
	echo -e "${GREEN}╰────────────────────────────────────────────────────╯${NC}"
	echo ""

	echo -e "${BLUE}Manage service:${NC}"
	echo -e "  sudo systemctl [start|stop|restart|status] ${SERVICE_UNIT}"
	echo ""
	echo -e "${BLUE}View logs:${NC}"
	echo -e "  sudo journalctl -u ${SERVICE_UNIT} -f"
	echo -e "  sudo journalctl -u ${DBUS_SERVICE_UNIT} -f"
	echo ""
	echo -e "${BLUE}Health check:${NC}"
	echo -e "  ${HOMEIO_HEALTHCHECK_URL}"
	echo ""
	if [[ -n "${BACKUP_DIR}" && -d "${BACKUP_DIR}" ]]; then
		echo -e "${BLUE}Backup location:${NC}"
		echo -e "  ${BACKUP_DIR}"
		echo ""
	fi
}

main() {
	require_root
	check_prerequisites
	# Before the rollback trap is armed: a GitHub hiccup only means main is used.
	resolve_repo_ref
	capture_current_state
	create_backup

	ROLLBACK_READY="true"
	trap 'on_error ${LINENO} $?' ERR

	ensure_service_shutdown_behavior
	stop_upload_server
	stop_service

	deploy_from_git

	ensure_security_dependencies
	install_go
	install_dependencies_if_needed
	run_database_migrations
	build_app
	build_upload_server
	start_service
	configure_reverse_proxy
	restart_dbus_helper_service
	restart_upload_server_service

	print_status "Running health check..."
	if ! healthcheck; then
		print_error "Health check failed at ${HOMEIO_HEALTHCHECK_URL}"; exit 1;
	fi

	ROLLBACK_READY="false"
	print_summary
}

main "$@"
bytes
28,495
lines
873
Type
sh
Latest commit
16 October 2026