Deploy: Proxmox VE community-scripts LXC installer (Gitea, build-from-source)
ci / build-test (push) Successful in 1m18s

Adds deploy/ct/metervault.sh (host) + deploy/install/metervault-install.sh (in-LXC), modeled on MQTTower's community-scripts installer but adapted for MeterVault: it lives on public Gitea (not GitHub) and is a single Blazor app + TimescaleDB (not multi-mode). The host script sources the community-scripts framework, creates a Debian 12 LXC and runs the install script via pct exec. The install script sets up PostgreSQL 16 + TimescaleDB, installs the .NET SDK, builds the app from the public Gitea repo (no prebuilt tarball exists — releases ship as a container image), writes /etc/metervault/environment + a systemd unit on port 8760, and installs an 'update' command (git pull + rebuild). scripts/run-metervault-ct-install.sh is the maintainer helper. All three pass shellcheck (warning-level clean) and bash -n. README documents the one-liner.

Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
This commit is contained in:
2026-07-17 11:18:27 +02:00
parent 8550ed8d9e
commit 18dd4f720d
4 changed files with 553 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: FinalFactory
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://git.finalfactory.de/FinalFactory/MeterVault
#
# Runs inside the LXC: installs PostgreSQL + TimescaleDB and the .NET SDK, builds the MeterVault
# Blazor app from the public Gitea repo, and runs it as a systemd service. MeterVault publishes a
# container image (not a prebuilt tarball), so — unlike MQTTower — this builds from source.
: "${GITEA_BASE:=https://git.finalfactory.de}"
: "${GITEA_REPO:=FinalFactory/MeterVault}"
: "${METERVAULT_REPO_URL:=${GITEA_BASE}/${GITEA_REPO}.git}"
: "${METERVAULT_BRANCH:=master}"
: "${METERVAULT_PORT:=8760}"
: "${METERVAULT_TIMEZONE:=Europe/Berlin}"
: "${METERVAULT_SEED:=false}"
: "${METERVAULT_DB_PASSWORD:=}"
: "${PG_VERSION:=16}"
: "${DOTNET_CHANNEL:=10.0}"
: "${INSTALL_DIR:=/opt/metervault}"
: "${SOURCE_DIR:=/opt/metervault-src}"
: "${ENV_FILE:=/etc/metervault/environment}"
: "${DB_NAME:=metervault}"
: "${DB_USER:=metervault}"
INSTALL_FUNC_URL="${INSTALL_FUNC_URL:-https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func}"
FUNCTIONS_FILE_PATH="${FUNCTIONS_FILE_PATH:-$(curl -fsSL "$INSTALL_FUNC_URL")}"
# shellcheck disable=SC1091 # community-scripts install.func is fetched at runtime
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
export APPLICATION="${APPLICATION:-MeterVault}"
export app="${app:-metervault}"
need_cmd() {
command -v "$1" >/dev/null 2>&1 || { msg_error "Missing command: $1"; exit 127; }
}
get_ipv4() {
hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true
}
rand_hex() {
openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p
}
# Unprivileged LXC: systemctl enable may fail creating unit symlinks; start often works anyway.
systemctl_enable_now_best_effort() {
local svc="$1"
if systemctl enable -q --now "$svc" 2>/dev/null; then
return 0
fi
msg_info "systemctl enable --now failed for ${svc}; trying start only (common in unprivileged LXC)."
systemctl daemon-reload 2>/dev/null || true
if systemctl start "$svc" 2>/dev/null && systemctl is-active --quiet "$svc" 2>/dev/null; then
return 0
fi
msg_error "Could not start ${svc}. See: journalctl -xeu ${svc}"
return 1
}
# Append KEY=VALUE only if KEY is absent (safe re-runs / new keys across versions).
ensure_env_key() {
local file="$1" key="$2" value="$3"
if ! grep -q "^${key}=" "$file" 2>/dev/null; then
printf '%s=%s\n' "$key" "$value" >>"$file"
fi
}
install_postgres_timescaledb() {
export DEBIAN_FRONTEND=noninteractive
msg_info "Adding PostgreSQL (PGDG) and TimescaleDB apt repositories"
$STD apt-get install -y gnupg postgresql-common apt-transport-https lsb-release wget ca-certificates
# PGDG: the official PostgreSQL apt repo (provides a consistent postgresql-${PG_VERSION}).
$STD /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
local codename
codename="$(lsb_release -cs)"
echo "deb https://packagecloud.io/timescale/timescaledb/debian/ ${codename} main" >/etc/apt/sources.list.d/timescaledb.list
wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg
$STD apt-get update
msg_ok "Repositories added"
msg_info "Installing PostgreSQL ${PG_VERSION} + TimescaleDB"
$STD apt-get install -y "postgresql-${PG_VERSION}" "postgresql-client-${PG_VERSION}" "timescaledb-2-postgresql-${PG_VERSION}"
msg_ok "Installed PostgreSQL + TimescaleDB"
msg_info "Enabling the timescaledb preload"
local pgconf="/etc/postgresql/${PG_VERSION}/main/postgresql.conf"
if command -v timescaledb-tune >/dev/null 2>&1; then
timescaledb-tune --quiet --yes --pg-config "/usr/lib/postgresql/${PG_VERSION}/bin/pg_config" >/dev/null 2>&1 || true
fi
if ! grep -qE "^\s*shared_preload_libraries\s*=.*timescaledb" "$pgconf" 2>/dev/null; then
echo "shared_preload_libraries = 'timescaledb'" >>"$pgconf"
fi
systemctl restart postgresql
msg_ok "PostgreSQL restarted with timescaledb preloaded"
}
provision_database() {
msg_info "Creating the ${DB_NAME} database and role"
[[ -n "${METERVAULT_DB_PASSWORD}" ]] || METERVAULT_DB_PASSWORD="$(rand_hex)"
# Role: create or reset the password (idempotent re-runs).
if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1; then
sudo -u postgres psql -c "ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${METERVAULT_DB_PASSWORD}';" >/dev/null
else
sudo -u postgres psql -c "CREATE ROLE ${DB_USER} WITH LOGIN PASSWORD '${METERVAULT_DB_PASSWORD}';" >/dev/null
fi
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" >/dev/null
fi
# Pre-create the extension as superuser (CREATE EXTENSION timescaledb needs superuser); the app's
# migration then finds it present and its own CREATE EXTENSION IF NOT EXISTS is a no-op.
sudo -u postgres psql -d "${DB_NAME}" -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" >/dev/null
msg_ok "Database ready"
}
install_dotnet_sdk() {
export DEBIAN_FRONTEND=noninteractive
msg_info "Installing the .NET SDK (${DOTNET_CHANNEL}) to build from source"
local deb_ver
deb_ver="$(. /etc/os-release 2>/dev/null && echo "${VERSION_ID%%.*}" || echo "12")"
if [[ "$deb_ver" != "11" && "$deb_ver" != "12" && "$deb_ver" != "13" ]]; then
deb_ver="12"
fi
if [[ ! -f /etc/apt/sources.list.d/microsoft-prod.list && ! -f /etc/apt/sources.list.d/microsoft-prod.sources ]]; then
$STD curl -fsSL "https://packages.microsoft.com/config/debian/${deb_ver}/packages-microsoft-prod.deb" -o /tmp/packages-microsoft-prod.deb
$STD dpkg -i /tmp/packages-microsoft-prod.deb
rm -f /tmp/packages-microsoft-prod.deb
fi
$STD apt-get update -y
$STD apt-get install -y "dotnet-sdk-${DOTNET_CHANNEL}"
msg_ok "Installed .NET SDK"
}
build_metervault() {
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
msg_info "Fetching MeterVault source (${METERVAULT_REPO_URL})"
if [[ -d "${SOURCE_DIR}/.git" ]]; then
git -C "${SOURCE_DIR}" fetch --depth 1 origin "${METERVAULT_BRANCH}"
git -C "${SOURCE_DIR}" reset --hard "origin/${METERVAULT_BRANCH}"
else
rm -rf "${SOURCE_DIR}"
$STD git clone --depth 1 --branch "${METERVAULT_BRANCH}" "${METERVAULT_REPO_URL}" "${SOURCE_DIR}"
fi
msg_ok "Source ready at ${SOURCE_DIR}"
msg_info "Building MeterVault (dotnet publish — this can take a few minutes)"
# sampledata/ is at the repo root; the App csproj links it as Content so publish ships the demo CSVs.
$STD dotnet publish "${SOURCE_DIR}/src/App/MeterVault.App.csproj" -c Release -o "${INSTALL_DIR}" /p:UseAppHost=false
msg_ok "Published to ${INSTALL_DIR}"
}
write_env() {
mkdir -p "$(dirname "${ENV_FILE}")"
umask 077
local conn="Host=127.0.0.1;Port=5432;Database=${DB_NAME};Username=${DB_USER};Password=${METERVAULT_DB_PASSWORD}"
if [[ -f "${ENV_FILE}" ]]; then
msg_info "Environment exists; merging new keys and refreshing the connection string"
ensure_env_key "${ENV_FILE}" "ASPNETCORE_ENVIRONMENT" "Production"
ensure_env_key "${ENV_FILE}" "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT" "false"
ensure_env_key "${ENV_FILE}" "ASPNETCORE_URLS" "http://+:${METERVAULT_PORT}"
ensure_env_key "${ENV_FILE}" "MeterVault__TimeZone" "${METERVAULT_TIMEZONE}"
ensure_env_key "${ENV_FILE}" "MeterVault__Currency" "EUR"
ensure_env_key "${ENV_FILE}" "MeterVault__Locale" "en"
ensure_env_key "${ENV_FILE}" "MeterVault__SeedReferenceData" "${METERVAULT_SEED}"
sed -i "s|^ConnectionStrings__Default=.*|ConnectionStrings__Default=${conn}|" "${ENV_FILE}"
grep -q '^ConnectionStrings__Default=' "${ENV_FILE}" || printf 'ConnectionStrings__Default=%s\n' "${conn}" >>"${ENV_FILE}"
msg_ok "Updated ${ENV_FILE}"
else
cat <<EOF >"${ENV_FILE}"
ASPNETCORE_ENVIRONMENT=Production
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ASPNETCORE_URLS=http://+:${METERVAULT_PORT}
ConnectionStrings__Default=${conn}
MeterVault__TimeZone=${METERVAULT_TIMEZONE}
MeterVault__Currency=EUR
MeterVault__Locale=en
MeterVault__SeedReferenceData=${METERVAULT_SEED}
EOF
msg_ok "Wrote ${ENV_FILE}"
fi
chmod 600 "${ENV_FILE}"
}
write_systemd() {
cat <<'EOF' >/etc/systemd/system/metervault.service
[Unit]
Description=MeterVault (self-hosted energy & utility metering)
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
EnvironmentFile=/etc/metervault/environment
WorkingDirectory=/opt/metervault
ExecStart=/usr/bin/dotnet /opt/metervault/MeterVault.App.dll
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
}
write_update_command() {
local url="${METERVAULT_CT_URL:-https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/deploy/ct/metervault.sh}"
cat <<EOF >/usr/bin/update
#!/usr/bin/env bash
exec bash -c "\$(curl -fsSL '${url}')"
EOF
chmod +x /usr/bin/update
}
main() {
need_cmd curl
ensure_dependencies git jq openssl sudo
install_postgres_timescaledb
provision_database
install_dotnet_sdk
build_metervault
write_env
write_systemd
systemctl daemon-reload 2>/dev/null || true
systemctl_enable_now_best_effort metervault
motd_ssh
customize 2>/dev/null || true
write_update_command
cleanup_lxc 2>/dev/null || true
local ip
ip="$(get_ipv4)"
msg_ok "MeterVault is up at http://${ip:-127.0.0.1}:${METERVAULT_PORT}"
}
main "$@"