homeio
PublicForked from doctor-io/homeio
update.sh
#!/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 "$@"