#!/usr/bin/env bash
# Portalis one-command install.
#
# curl -fsSL https://get.portalis.sh | sudo bash
#
# Needs Docker + Compose v2; when missing it offers to install them
# (PORTALIS_INSTALL_DOCKER=1 does so without asking, for unattended runs).
#
# Pulls the prebuilt container image (no source, no compilation), brings up the
# app + PostgreSQL + Redis, generates all secrets and a random first-login
# superadmin password. Re-running is safe — it keeps the existing configuration
# and simply updates to the newest image.
#
# Optional overrides (set as env vars before running; use `sudo -E`). They are
# remembered in
/.env, so a re-run keeps them unless you pass the variable
# again: explicit env > .env > default.
# PORTALIS_DIR install directory (default /opt/portalis)
# PORTALIS_IMAGE image repo, no tag (default ghcr.io/solutionmax/portalis)
# PORTALIS_VERSION image tag; pins updates (default latest)
# PORTALIS_PORT web UI port (default 8090)
# PORTALIS_SSH_PORT SSH gateway port (default 2222)
# PORTALIS_BIND address to publish on (default 0.0.0.0 = all)
# set to 127.0.0.1 when a reverse proxy terminates TLS
# PORTALIS_TRUST_PROXY 1 = trust X-Forwarded-For from private/loopback peers
# (default 1 when bound to loopback, else 0)
# PORTALIS_TRUSTED_PROXIES CIDRs allowed to set X-Forwarded-For, comma-separated
# (default 127.0.0.1/32,::1/128 when bound to loopback)
# PORTALIS_INSTALL_DOCKER 1 = install Docker without asking when it is missing
#
# Subcommands:
# curl -fsSL https://get.portalis.sh | sudo bash -s -- uninstall [--purge]
# stop the containers and remove the portalis-ota-* systemd units;
# --purge also deletes the Docker volumes and the install directory.
# curl -fsSL https://get.portalis.sh | sudo bash -s -- refresh-ota
# rewrite only the host OTA scripts (ota/*.sh) and their systemd units
# from this installer: no image pull, no container restart, no .env
# change. An OTA update calls this so the host side of a new release
# arrives with it.
set -Eeuo pipefail
BOLD=$'\033[1m'; DIM=$'\033[2m'; RST=$'\033[0m'
CYAN=$'\033[1;36m'; GREEN=$'\033[1;32m'; RED=$'\033[1;31m'; YEL=$'\033[1;33m'; BLUE=$'\033[1;34m'
step() { STEP=$*; printf "${CYAN}▸${RST} ${BOLD}%s${RST}\n" "$*"; }
info() { printf " ${DIM}%s${RST}\n" "$*"; }
die() { printf "\n${RED}✖ %s${RST}\n" "$*" >&2; exit 1; }
LICENSE_PUBKEY_DEFAULT=aad11a2287ba0abba47d349c3e99c80adfde46b9ac00b09a98a7a5fa34cb3731
LICENSE_SERVER_DEFAULT=https://my.portalis.sh
DIR=${PORTALIS_DIR:-/opt/portalis}
# Where the portalis-ota-* systemd units live. Overridable so the smoke test can
# point it at a scratch directory instead of the host's real unit directory.
UNITS=${PORTALIS_SYSTEMD_DIR:-/etc/systemd/system}
# Settings remembered from an earlier run (B4): a re-run is the documented
# update path, and silently falling back to defaults would re-expose a
# loopback-bound install to the network. Explicit env still wins.
SETTINGS=(PORTALIS_IMAGE PORTALIS_VERSION PORTALIS_PORT PORTALIS_SSH_PORT PORTALIS_BIND PORTALIS_TRUST_PROXY PORTALIS_TRUSTED_PROXIES PORTALIS_INSTANCE_NAME)
for k in "${SETTINGS[@]}"; do
[[ -z "${!k:-}" && -f "$DIR/.env" ]] || continue
v=$(sed -n "s/^$k=//p" "$DIR/.env" | tail -1)
[[ -n "$v" ]] && declare "$k=$v"
done
IMAGE_REPO=${PORTALIS_IMAGE:-ghcr.io/solutionmax/portalis}
VERSION=${PORTALIS_VERSION:-latest}
[[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "PORTALIS_VERSION '$VERSION' is not a valid image tag (e.g. 1.0.9 or latest)"
PORT=${PORTALIS_PORT:-8090}
SSH_PORT=${PORTALIS_SSH_PORT:-2222}
# Publishing on all interfaces is the sane default for a first install, but it
# means plain HTTP is reachable from the network. Behind a TLS proxy you want
# 127.0.0.1 so the only way in is through the proxy.
BIND=${PORTALIS_BIND:-0.0.0.0}
[[ "$BIND" == localhost ]] && BIND=127.0.0.1 # Docker's port mapping wants an IP
# ponytail: IPv6 is accepted unvalidated — Docker rejects garbage itself.
[[ "$BIND" =~ ^[0-9]+(\.[0-9]+){3}$ || "$BIND" == *:* ]] || die "PORTALIS_BIND must be an IP address, got '$BIND'"
# Trusting X-Forwarded-For means whoever connects decides what IP we record. That
# is right behind a reverse proxy and wrong everywhere else: without one, the
# client is the peer, so it can name any address it likes — which forges the
# audit trail, walks past the IP allowlist and defeats the login rate limit.
# Default off; on automatically when the app is bound to loopback, because then a
# proxy is the only way in. TRUSTED_PROXIES is the precise form (CIDR list, wins
# in the app when set); TRUST_PROXY=1 stays as the legacy "any private peer".
case "$BIND" in
127.0.0.1|::1) TRUST_DEFAULT=1; TRUSTED_DEFAULT=127.0.0.1/32,::1/128 ;;
*) TRUST_DEFAULT=0; TRUSTED_DEFAULT= ;;
esac
TRUST_PROXY=${PORTALIS_TRUST_PROXY:-$TRUST_DEFAULT}
TRUSTED_PROXIES=${PORTALIS_TRUSTED_PROXIES:-$TRUSTED_DEFAULT}
# Where this host can reach the web UI for the health probe.
case "$BIND" in 0.0.0.0) PROBE=127.0.0.1 ;; *:*) PROBE="[$BIND]" ;; *) PROBE=$BIND ;; esac
# Anything that trips set -e: say which step, and that a re-run resumes.
on_err() {
printf "\n${RED}✖ failed during: %s${RST}\n" "${STEP:-startup}" >&2
[[ -f "$DIR/.env" ]] && printf " ${DIM}Configuration is kept in %s/.env — fix the cause and re-run the same command; it picks up where it left off.${RST}\n" "$DIR" >&2
true
}
trap on_err ERR
# uninstall [--purge]: undo everything the installer wired up on the host.
uninstall() {
step "Uninstalling Portalis from $DIR"
if [[ -d /run/systemd/system ]]; then
systemctl disable --now portalis-ota-check.timer portalis-ota-apply.path portalis-ota-recheck.path >/dev/null 2>&1 || true
rm -f "$UNITS"/portalis-ota-*
systemctl daemon-reload
info "removed the portalis-ota-* systemd units"
fi
if [[ -f "$DIR/docker-compose.yml" ]]; then
if [[ "$1" == --purge ]]; then
(cd "$DIR" && docker compose down -v) && rm -rf "$DIR"
info "removed containers, volumes (database, host keys, recordings) and $DIR"
else
(cd "$DIR" && docker compose down)
info "removed containers; kept $DIR (.env, compose, ota-state) and the Docker volumes"
info "re-run the installer to bring it back, or 'uninstall --purge' to delete everything"
fi
fi
exit 0
}
# need_docker: Docker + Compose v2 must be present; offer to install them via
# the official convenience script. We are usually run as `curl | sudo bash`, so
# stdin is this script — the question is asked on /dev/tty. No terminal (CI,
# cloud-init): PORTALIS_INSTALL_DOCKER=1 installs unattended, otherwise we stop.
need_docker() {
command -v docker >/dev/null && docker compose version >/dev/null 2>&1 && return 0
local ans=y tmp
if [[ "${PORTALIS_INSTALL_DOCKER:-}" != 1 ]]; then
# read -p prints its prompt on stderr; write it to the tty ourselves so it
# is visible even when stderr is redirected (curl ... | sudo bash 2>log).
{ : /dev/null \
|| die "Docker is not installed and there is no terminal to ask. Either re-run with PORTALIS_INSTALL_DOCKER=1 to install it unattended, or install Docker manually (https://docs.docker.com/engine/install/) and re-run."
printf 'Docker is not installed. Would you like to install Docker now? [Y/n] ' >/dev/tty
read -r ans /dev/null && echo "${ID:-}")" in
almalinux|rocky|rhel|ol)
dnf -y -q install dnf-plugins-core \
&& dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo \
&& dnf -y -q install docker-ce docker-ce-cli containerd.io docker-compose-plugin \
|| { rm -f "$tmp"; die "could not install Docker from the CentOS repo — install it manually (https://docs.docker.com/engine/install/) and re-run."; } ;;
*) rm -f "$tmp"; die "could not install Docker automatically — install it manually (https://docs.docker.com/engine/install/) and re-run." ;;
esac
fi
rm -f "$tmp"
[[ -d /run/systemd/system ]] && systemctl enable --now docker >/dev/null 2>&1 || true
docker compose version >/dev/null 2>&1 \
|| die "Docker was installed but 'docker compose version' does not work — check the Docker install and re-run."
}
# write_ota_files (re)writes the host side of the over-the-air updater: three
# small scripts plus systemd units that (a) check ghcr for a newer image every
# 30 min, (b) apply an update when the app drops a trigger, (c) re-check on
# demand. Image-based — it compares the running image digest to the registry's;
# no source repo needed.
#
# Split out of setup_ota so `refresh-ota` can refresh these on their own: the
# scripts ship with the installer, not with the image, so an update would
# otherwise keep running the host scripts of whichever installer first ran here.
#
# The scripts are written to a .tmp file and renamed into place, never truncated
# in place: ota/update.sh calls refresh-ota while it is still executing, and
# bash re-reads a running script from its file offset — overwriting that inode
# would make it resume in the middle of the new text.
write_ota_files() {
mkdir -p "$DIR/ota"
cat > "$DIR/ota/check.sh.tmp" <<'SH'
#!/usr/bin/env bash
# OTA check (image-based): compare the running image digest to the registry's
# and record the result the app reads. Never exits non-zero and always writes
# update-status.json: a failed check is a status the UI must be able to show,
# not a reason for the "Check for updates" button to vanish. Anonymous token
# works for a public image. Image repo and tag come from the installer's .env.
set -uo pipefail
DIR="${PORTALIS_DIR:-/opt/portalis}"
STATE="$DIR/ota-state"
cfg() { sed -n "s/^$1=//p" "$DIR/.env" 2>/dev/null | tail -1; }
IMAGE_REPO=$(cfg PORTALIS_IMAGE); IMAGE_REPO=${IMAGE_REPO:-ghcr.io/solutionmax/portalis}
TAG=$(cfg PORTALIS_VERSION); TAG=${TAG:-latest}
REPO_PATH="${IMAGE_REPO#ghcr.io/}"
mkdir -p "$STATE"
# JSON string escaping without python: backslash and quote escaped, control
# characters dropped. ponytail: dropped rather than \u-escaped — the inputs are
# one-line curl/docker messages, never user text.
escq() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }
esc() { escq "$1" | tr -d '\000-\037'; }
# Same escaping, but line breaks survive as \n. Release notes are markdown, and
# esc's control-character purge would run all the lines together.
escn() { escq "$1" | tr -d '\000-\011\013-\037' | sed -e ':a;N;$!ba;s/\n/\\n/g'; }
# jsonfield : the value of a top-level string field of the FIRST object in
# releases.json, with the JSON escapes decoded. Used when jq is absent — a bare
# Debian or Alma has neither jq nor python, and this file must run anywhere.
# It is a real (small) JSON scanner rather than a regex: notes contain quotes,
# backslashes and \n, all of which a `sed -n 's/.*"notes":"\([^"]*\)".*/\1/p'`
# would cut in the wrong place. LC_ALL=C so substr() walks bytes, not runes.
jsonfield() {
LC_ALL=C awk -v want="$1" '
function uchar(h, v, i, c, d) { # \uXXXX -> UTF-8 bytes (BMP only)
v = 0
for (i = 1; i <= 4; i++) {
c = tolower(substr(h, i, 1)); d = index("0123456789abcdef", c) - 1
if (d < 0) return ""
v = v * 16 + d
}
if (v < 128) return sprintf("%c", v)
if (v < 2048) return sprintf("%c%c", 192 + int(v / 64), 128 + v % 64)
return sprintf("%c%c%c", 224 + int(v / 4096), 128 + int(v / 64) % 64, 128 + v % 64)
}
function readstr( out, ch) { # p sits just past the opening quote
out = ""
while (p <= n) {
ch = substr(S, p, 1); p++
if (ch == "\\") {
ch = substr(S, p, 1); p++
if (ch == "n") out = out "\n"
else if (ch == "t") out = out "\t"
else if (ch == "r") out = out "\r"
else if (ch == "b" || ch == "f") out = out " "
else if (ch == "u") { out = out uchar(substr(S, p, 4)); p += 4 }
else out = out ch # \" \\ \/ and anything unknown
} else if (ch == "\"") return out
else out = out ch
}
return out
}
function skipval( depth, ch) { # a value we were not asked for
ch = substr(S, p, 1)
if (ch != "{" && ch != "[") { while (p <= n && index(",}", substr(S, p, 1)) == 0) p++; return }
depth = 0
while (p <= n) {
ch = substr(S, p, 1); p++
if (ch == "\"") readstr()
else if (ch == "{" || ch == "[") depth++
else if (ch == "}" || ch == "]") { depth--; if (depth == 0) return }
}
}
function ws() { while (p <= n && index(" \t\r\n", substr(S, p, 1)) > 0) p++ }
{ S = S $0 "\n" }
END {
n = length(S); p = index(S, "{")
if (p == 0) exit
p++
while (p <= n) {
ws(); if (substr(S, p, 1) == ",") { p++; ws() }
if (substr(S, p, 1) != "\"") exit
p++; key = readstr()
ws(); if (substr(S, p, 1) != ":") exit
p++; ws()
if (substr(S, p, 1) == "\"") { p++; val = readstr(); if (key == want) { printf "%s", val; exit } }
else skipval()
}
}'
}
err=""; remote=""
if out=$(curl -fsS "https://ghcr.io/token?scope=repository:${REPO_PATH}:pull&service=ghcr.io" 2>&1); then
tok=$(printf '%s' "$out" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
if out=$(curl -fsSI \
-H "Authorization: Bearer ${tok}" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
"https://ghcr.io/v2/${REPO_PATH}/manifests/${TAG}" 2>&1); then
remote=$(printf '%s' "$out" | tr -d '\r' | awk 'tolower($1)=="docker-content-digest:"{print $2}')
[[ -n "$remote" ]] || err="registry returned no digest for ${IMAGE_REPO}:${TAG}"
else err="registry: ${out}"; fi
else err="registry token: ${out}"; fi
running=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${IMAGE_REPO}:${TAG}" 2>/dev/null \
| sed -n 's#.*@##p' | head -1)
avail=false; pinned=false
[[ -n "$remote" && -n "$running" && "$remote" != "$running" ]] && avail=true
# A pinned tag still gets checked (so the UI can say "pinned to X"); it only
# flips to available if that same tag was re-pushed.
[[ "$TAG" != latest ]] && pinned=true
[[ -n "$err" ]] && echo "check failed: $err" >&2
# Release notes and the human version number come from get.portalis.sh: a
# registry digest carries no changelog and the configured tag is usually
# "latest", which tells a user nothing. Best effort with a short timeout — a
# missing, unreachable or malformed releases.json leaves the tag-derived version
# in place, empty notes, and is explicitly NOT a check error: the registry
# comparison above is what decides whether an update exists.
# OTA_JSON=awk forces the jq-free reader (used by the smoke test).
version="$TAG"; notes=""
if rel=$(curl -fsS --max-time 10 "${OTA_RELEASES_URL:-https://get.portalis.sh/releases.json}" 2>/dev/null); then
if [[ "${OTA_JSON:-}" != awk ]] && command -v jq >/dev/null 2>&1; then
v=$(printf '%s' "$rel" | jq -r 'if type=="array" and length>0 then (.[0].version // "") else "" end' 2>/dev/null) || v=""
notes=$(printf '%s' "$rel" | jq -r 'if type=="array" and length>0 then (.[0].notes // "") else "" end' 2>/dev/null) || notes=""
else
v=$(printf '%s' "$rel" | jsonfield version)
notes=$(printf '%s' "$rel" | jsonfield notes)
fi
# Only a plausible tag replaces the configured one; junk is ignored.
[[ "$v" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$ ]] && version="$v"
fi
notes=${notes:0:2000}
# latestSha is left empty on purpose: it's the git-commit field the app's legacy
# updater compares against BuildSHA. An image digest there would never match the
# commit and would trip the fallback into a permanent "update available". The
# image checker is authoritative via updateAvailable; the digest lives in a
# separate field for debugging only. updateError is what update.sh passes in
# after a failed apply; it clears on the next scheduled check.
now=$(date -Iseconds)
cat > "$STATE/update-status.json" < "$DIR/ota/update.sh.tmp" <<'SH'
#!/usr/bin/env bash
# OTA apply: pull the newest image, recreate the stack, probe the web UI; if it
# does not come back, re-tag the previously running image and bring that up.
# The trigger is removed whatever happens, so the UI never sticks on "Updating".
set -uo pipefail
DIR="${PORTALIS_DIR:-/opt/portalis}"
STATE="$DIR/ota-state"; LOG="$STATE/update.log"; TRIGGER="$STATE/update.trigger"
trap 'rm -f "$TRIGGER" "$STATE/update-stage"' EXIT
# The app shows these as steps while the update runs (pull → restart → health,
# rollback when the new image does not come up).
stage() { echo "$1" > "$STATE/update-stage"; chmod 0644 "$STATE/update-stage"; echo "--- stage: $1 $(date -Iseconds)"; }
cd "$DIR" || exit 1
cfg() { sed -n "s/^$1=//p" .env 2>/dev/null | tail -1; }
IMAGE_REPO=$(cfg PORTALIS_IMAGE); IMAGE_REPO=${IMAGE_REPO:-ghcr.io/solutionmax/portalis}
TAG=$(cfg PORTALIS_VERSION); TAG=${TAG:-latest}
PORT=$(cfg PORTALIS_PORT); PORT=${PORT:-8090}
BIND=$(cfg PORTALIS_BIND); BIND=${BIND:-0.0.0.0}
case "$BIND" in 0.0.0.0) PROBE=127.0.0.1 ;; *:*) PROBE="[$BIND]" ;; *) PROBE=$BIND ;; esac
healthy() { local t=0; until curl -fsS -o /dev/null "http://${PROBE}:${PORT}/"; do (( t++ >= 60 )) && return 1; sleep 1; done; }
finish() { # $1 = error message, empty on success
echo "=== done $(date -Iseconds) ${1:+FAILED: $1 }==="
OTA_UPDATE_ERROR="$1" PORTALIS_DIR="$DIR" bash "$DIR/ota/check.sh"
[[ -z "$1" ]] && exit 0; exit 1
}
exec >> "$LOG" 2>&1
echo "=== update $(date -Iseconds) ==="
old=$(docker image inspect --format '{{.Id}}' "${IMAGE_REPO}:${TAG}" 2>/dev/null || true)
stage pull
docker compose pull || finish "image pull failed (see ota-state/update.log)"
stage restart
if docker compose up -d && { stage health; healthy; }; then
# The host scripts (this one included) ship with the installer, not with the
# image, so a release that changes them only lands if we fetch them here.
# refresh-ota rewrites ota/*.sh + the units and nothing else — no pull, no
# restart, so it cannot recurse back into this update — and it renames the
# new files into place, so this running script keeps its own inode.
curl -fsSL https://get.portalis.sh | bash -s -- refresh-ota >>"$LOG" 2>&1 || echo "--- refresh-ota failed (non-fatal)"
finish ""
fi
echo "--- new image did not come online, rolling back to ${old:-}"
stage rollback
# ponytail: rollback = point the tag back at the old image ID; no separate
# rollback tag, no compose edits. The next check still reports the update.
[[ -n "$old" ]] && docker tag "$old" "${IMAGE_REPO}:${TAG}" && docker compose up -d
finish "update failed, rolled back to the previous image (see ota-state/update.log)"
SH
cat > "$DIR/ota/recheck.sh.tmp" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
DIR="${PORTALIS_DIR:-/opt/portalis}"
PORTALIS_DIR="$DIR" bash "$DIR/ota/check.sh" || true
rm -f "$DIR/ota-state/check.trigger"
SH
for f in check.sh update.sh recheck.sh; do
chmod +x "$DIR/ota/$f.tmp"
mv -f "$DIR/ota/$f.tmp" "$DIR/ota/$f"
done
mkdir -p "$UNITS"
cat > "$UNITS/portalis-ota-check.service" < "$UNITS/portalis-ota-check.timer" < "$UNITS/portalis-ota-apply.service" < "$UNITS/portalis-ota-apply.path" < "$UNITS/portalis-ota-recheck.service" < "$UNITS/portalis-ota-recheck.path" </dev/null 2>&1 || true
}
# setup_ota: what a full install/update run does — write the files, seed a first
# status, arm the units. Skips cleanly (leaving the app in "manual update" mode)
# when systemd is absent.
setup_ota() {
if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then
info "systemd not detected — skipping auto-updater (manual: re-run this installer)"
return 0
fi
write_ota_files
# Seed an initial status so the app reports "managed" right away.
PORTALIS_DIR="$DIR" "$DIR/ota/check.sh" 2>/dev/null || true
ota_units_reload
info "auto-updater installed (checks every 30 min; one-click update in Settings → About)"
}
# refresh-ota: rewrite the host OTA scripts and units, and nothing else.
# ota/update.sh calls this after a successful update so a new release can ship
# a new checker/updater. Deliberately does NOT pull an image, restart the stack,
# touch .env or run update.sh — the last of those would recurse straight back
# into the update that called it.
refresh_ota() {
step "Refreshing the Portalis OTA host scripts in $DIR"
[[ $(id -u) -eq 0 ]] || die "run as root (sudo)"
[[ -d "$DIR" ]] || die "$DIR does not exist — run the installer first"
write_ota_files
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
ota_units_reload
info "rewrote ota/check.sh, ota/update.sh, ota/recheck.sh and the portalis-ota-* units"
else
info "rewrote ota/check.sh, ota/update.sh, ota/recheck.sh (no systemd — units not armed)"
fi
exit 0
}
# Every run is appended to a log, so support can ask for one file instead of
# a screenshot. The file copy has colours and spinner frames stripped and the
# bootstrap password masked; the terminal output is unchanged.
LOGFILE=${PORTALIS_LOG:-/var/log/portalis-install.log}
if touch "$LOGFILE" 2>/dev/null; then
chmod 0600 "$LOGFILE"
printf -- '── %s args: %s (%s)\n' "$(date -Is)" "${*:-install}" "$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown os}")" >> "$LOGFILE"
exec > >(tee >(sed -u 's/.*\r//; s/\x1b\[[0-9;]*m//g; s/\(Password *: \).*/\1********/' >> "$LOGFILE")) 2>&1
fi
# refresh-ota is dispatched here, before the banner: ota/update.sh calls it, so
# it must print log lines only — no ASCII art and no bootstrap-password box.
[[ "${1:-}" == refresh-ota ]] && refresh_ota
printf "\n${BLUE}"
cat <<'ART'
____ _ _ _
| _ \ ___ _ __| |_ __ _| (_)___
| |_) / _ \| '__| __/ _` | | / __|
| __/ (_) | | | || (_| | | \__ \
|_| \___/|_| \__\__,_|_|_|___/
ART
printf "${RST}${DIM} self-hosted SSH gateway · session recording · JIT access${RST}\n\n"
# ── 1/4 prerequisites ───────────────────────────────────────────────
step "[1/4] Checking prerequisites"
[[ $(id -u) -eq 0 ]] || die "run as root (sudo)"
[[ "${1:-}" == uninstall ]] && uninstall "${2:-}"
command -v openssl >/dev/null || die "openssl not found"
command -v curl >/dev/null || die "curl not found"
need_docker
info "docker, compose, openssl, curl present"
# ── 2/4 configuration ───────────────────────────────────────────────
step "[2/4] Preparing configuration in $DIR"
mkdir -p "$DIR"
cd "$DIR"
cat > docker-compose.yml < .env </dev/null || hostname)}
EOF
elif [[ -f .installed ]]; then
info "existing installation — keeping .env, updating image"
# Installs from before 1.0.23 have no instance name yet; add the host's.
grep -q '^PORTALIS_INSTANCE_NAME=' .env || echo "PORTALIS_INSTANCE_NAME=${PORTALIS_INSTANCE_NAME:-$(hostname -f 2>/dev/null || hostname)}" >> .env
else
info "previous run did not finish — resuming with the existing .env"
fi
BOOTSTRAP_PW=$(grep '^PORTALIS_BOOTSTRAP_PASSWORD=' .env | cut -d= -f2-)
# .installed is written only once the stack is healthy (B2): until then the
# bootstrap password is still "new" and must be printed again on re-run.
FRESH=1; [[ -f .installed ]] && FRESH=0
# Remember the deployment settings for re-runs and the OTA scripts. Derived
# values (TRUST_PROXY, TRUSTED_PROXIES) are stored only when set explicitly, so
# they keep following PORTALIS_BIND.
sed -i '/^# Deployment settings/d;/^PORTALIS_\(IMAGE\|VERSION\|PORT\|SSH_PORT\|BIND\|TRUST_PROXY\|TRUSTED_PROXIES\)=/d' .env
cat >> .env </dev/null || true
# ── 3/4 pull & start ────────────────────────────────────────────────
step "[3/4] Pulling image and starting containers"
info "image: ${IMAGE_REPO}:${VERSION}"
docker compose pull -q
docker compose up -d
# ── 4/4 wait for health ─────────────────────────────────────────────
step "[4/4] Waiting for Portalis to come online"
UP=0; start=$SECONDS; frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'; i=0
while (( SECONDS - start < 120 )); do
if curl -fsS -o /dev/null "http://${PROBE}:${PORT}/" 2>/dev/null; then UP=1; break; fi
i=$(( (i + 1) % 10 ))
printf "\r ${CYAN}%s${RST} starting services… ${DIM}%ds${RST} " "${frames:$i:1}" "$(( SECONDS - start ))"
sleep 0.25
done
printf "\r\033[K"
if [[ $UP -ne 1 ]]; then
printf "${RED}✖ Portalis did not come online within 120s.${RST}\n\n"
echo "Most recent app logs:"
docker compose logs --tail 25 portalis 2>&1 | sed 's/^/ /'
echo
echo "Common causes: port ${PORT} already in use, or the database not ready."
echo "Fix it and re-run this installer — configuration is kept in ${DIR}/.env."
exit 1
fi
touch .installed
# Wire the host-side auto-updater (best-effort; manual re-run still works).
setup_ota
HOST=$(hostname -I 2>/dev/null | awk '{print $1}'); HOST=${HOST:-localhost}
[[ "$BIND" == 0.0.0.0 ]] || HOST=$BIND
printf "\n${GREEN}"
cat <<'ART'
✔ PORTALIS IS LIVE
┌──────────────────────────────────────────────────────────┐
ART
printf "${RST}"
printf "${GREEN} │${RST} Web UI : ${BOLD}http://%s:%s${RST}\n" "$HOST" "$PORT"
printf "${GREEN} │${RST} SSH gate : ${BOLD}%s${RST} port ${BOLD}%s${RST}\n" "$HOST" "$SSH_PORT"
printf "${GREEN} │${RST} Username : ${BOLD}superadmin${RST}\n"
if [[ $FRESH -eq 1 ]]; then
printf "${GREEN} │${RST} Password : ${YEL}%s${RST}\n" "$BOOTSTRAP_PW"
printf "${GREEN} └──────────────────────────────────────────────────────────┘${RST}\n"
echo
printf " ${YEL}⚠ Write this password down now — it is not shown again.${RST}\n"
echo " (It stays in ${DIR}/.env as PORTALIS_BOOTSTRAP_PASSWORD until first login replaces it.)"
echo " First login forces a password change + 2FA setup."
echo " Need a paid tier? Settings → License → Request or upgrade a license."
else
printf "${GREEN} │${RST} Password : ${DIM}(unchanged — set at first install)${RST}\n"
printf "${GREEN} └──────────────────────────────────────────────────────────┘${RST}\n"
echo
printf " ${GREEN}✔ Updated to the newest image.${RST}\n"
fi
echo
# Portalis carries SSH credentials and records full terminal sessions, and it
# listens on plain HTTP. Saying nothing here leaves people to discover that the
# hard way, so spell it out at the one moment they are certain to be reading.
if [[ "$BIND" == "127.0.0.1" || "$BIND" == "::1" ]]; then
printf " ${GREEN}✔ Web UI bound to %s — only reachable through your proxy.${RST}\n" "$BIND"
echo
else
printf " ${YEL}⚠ This install serves plain HTTP — there is no TLS.${RST}\n"
echo " Passwords, 2FA codes and everything typed in a terminal session"
echo " travel unencrypted over your network."
echo
echo " Fine on a trusted, isolated network. For anything else, put TLS in"
echo " front of it. With Caddy that is two lines:"
echo
printf " ${BOLD}portalis.your-domain.com {${RST}\n"
printf " ${BOLD} reverse_proxy 127.0.0.1:%s${RST}\n" "$PORT"
printf " ${BOLD}}${RST}\n"
echo
echo " Then re-run this installer with PORTALIS_BIND=127.0.0.1 so the app is"
echo " no longer reachable directly from the network."
echo
fi