Anatomy of an install script
Every block of install/<app>-install.sh, annotated with real scripts, plus the Alpine variant.
An install script runs inside the freshly created container. This page follows
install/fluid-calendar-install.sh
from top to bottom, because it contains every common block: packages, a database,
a runtime, a release download, an .env file, credentials and a systemd unit.
The whole file
#!/usr/bin/env bash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: vhsdream
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://github.com/dotnetfactory/fluid-calendar
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
msg_info "Installing Dependencies"
$STD apt install -y \
build-essential \
zip
msg_ok "Installed Dependencies"
PG_VERSION="17" setup_postgresql
PG_DB_NAME="fluiddb" PG_DB_USER="fluiduser" setup_postgresql_db
NODE_VERSION="24" setup_nodejs
fetch_and_deploy_gh_release "fluid-calendar" "dotnetfactory/fluid-calendar" "tarball"
msg_info "Configuring fluid-calendar"
NEXTAUTH_SECRET="$(random_password 32)"
echo "NextAuth Secret: $NEXTAUTH_SECRET" >>~/$APPLICATION.creds
cat <<EOF >/opt/fluid-calendar/.env
DATABASE_URL="postgresql://${PG_DB_USER}:${PG_DB_PASS}@localhost:5432/${PG_DB_NAME}"
# Change the URL below to your external URL
NEXTAUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXTAUTH_SECRET="${NEXTAUTH_SECRET}"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXT_PUBLIC_ENABLE_SAAS_FEATURES=false
RESEND_API_KEY=
RESEND_EMAIL=
EOF
export NEXT_TELEMETRY_DISABLED=1
cd /opt/fluid-calendar
$STD npm install --legacy-peer-deps
$STD npm run prisma:generate
$STD npx prisma migrate deploy
$STD npm run build:os
msg_ok "Configured fluid-calendar"
msg_info "Creating Service"
cat <<EOF >/etc/systemd/system/fluid-calendar.service
[Unit]
Description=Fluid Calendar Application
After=network.target postgresql.service
[Service]
Restart=always
WorkingDirectory=/opt/fluid-calendar
EnvironmentFile=/opt/fluid-calendar/.env
ExecStart=/usr/bin/npm run start
[Install]
WantedBy=multi-user.target
EOF
systemctl enable -q --now fluid-calendar
msg_ok "Created Service"
motd_ssh
customize
cleanup_lxcBootstrap
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_osThese seven lines are the same in every install script, in this order.
| Line | What it does |
|---|---|
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" | Loads lxc/install.func, which the host passed in as a variable. It installs curl if the image has none, downloads the engine's runtime and error handler, and detects the system: OS_TYPE, OS_FAMILY, OS_VERSION, PKG_MANAGER, INIT_SYSTEM |
color | Sets the colour variables for messages |
verb_ip6 | Applies silent or verbose output ($STD). If the user chose to disable IPv6 it writes /etc/sysctl.d/99-disable-ipv6.conf |
catch_errors | set -Ee -o pipefail and the error traps. From here on a failing command ends the install |
setting_up_container | Prints Setting up Container OS. Waits for an IP address (10 tries, 3 seconds apart, then No Network After 10 Tries and exit 1), fixes /etc permissions and /etc/timezone where an image got them wrong, removes Python's EXTERNALLY-MANAGED marker, sets a C.UTF-8 locale for apt, and ends with Network Connected: <ip> |
network_check | Tests IPv4 and IPv6 connectivity, then name resolution for github.com, raw.githubusercontent.com and api.github.com. The result is the Git DNS: line. A host that does not resolve ends the install |
update_os | Prints Updating Container OS, configures a proxy or APT cacher if one was chosen, refreshes the package index and upgrades all packages, then loads the helper library |
Two consequences worth knowing:
- Helpers exist only after
update_os.fetch_and_deploy_gh_release,setup_nodejs,setup_postgresqland the rest come fromlib/tools.func(lib/alpine.funcon Alpine), whichupdate_osloads at its end. - The same lines work on every distribution.
install.funcpicks apt, apk, dnf, zypper, pacman or emerge by itself. There is no separate Alpine bootstrap.
Each of setting_up_container, update_os, motd_ssh and customize also sends
a small progress ping to the telemetry service when the user enabled diagnostics,
so a long install is not mistaken for a stuck one. The final result is reported by
the host, not by the install script. See Telemetry.
Dependencies
msg_info "Installing Dependencies"
$STD apt install -y \
build-essential \
zip
msg_ok "Installed Dependencies"msg_infostarts a line with a spinner,msg_okreplaces it with a check mark. They come in pairs.$STDhides the command's output in a default install and shows it in verbose mode. The output is always written to the install log.- One package per line keeps diffs readable.
- Do not install
curl,sudo,mc,gnupg2orjq. The host already installed them before the script started.
In an update function, where a package might already be there, scripts use
ensure_dependencies build-essential instead; it installs only what is missing.
Runtimes and databases
PG_VERSION="17" setup_postgresql
PG_DB_NAME="fluiddb" PG_DB_USER="fluiduser" setup_postgresql_db
NODE_VERSION="24" setup_nodejsRuntime and database helpers take their options as variables written in front of
the call. They print their own progress lines, so they are not wrapped in
msg_info/msg_ok.
| Call | Example from | What you get |
|---|---|---|
NODE_VERSION="22" setup_nodejs | install/uptimekuma-install.sh | Node.js from the NodeSource repository. NODE_MODULE="pnpm" adds global modules |
PYTHON_VERSION="3.13" setup_uv | 24 install scripts | uv, and the requested Python |
PG_VERSION="17" setup_postgresql | install/miniflux-install.sh | PostgreSQL server |
PG_DB_NAME=... PG_DB_USER=... setup_postgresql_db | install/fluid-calendar-install.sh | Role and database, a generated PG_DB_PASS, credentials appended to ~/<app>.creds |
setup_mariadb, then MARIADB_DB_NAME=... MARIADB_DB_USER=... setup_mariadb_db | install/bookstack-install.sh | MariaDB server, database and user, MARIADB_DB_PASS |
PHP_VERSION="8.3" PHP_APACHE="YES" PHP_FPM="YES" PHP_MODULE="ldap,tidy,mysqli" setup_php | install/bookstack-install.sh | PHP with the listed modules |
setup_composer | install/bookstack-install.sh | Composer in /usr/local/bin |
setup_docker | install/docker-install.sh | Docker Engine |
After setup_postgresql_db the variables PG_DB_NAME, PG_DB_USER and
PG_DB_PASS are exported, which is how the .env file further down gets its
connection string. The complete list of helpers and their options is in the
function reference: runtimes and
databases.
Release deploy
fetch_and_deploy_gh_release "fluid-calendar" "dotnetfactory/fluid-calendar" "tarball"fetch_and_deploy_gh_release <app> <owner/repo> [mode] [version] [target] [asset_pattern]
downloads a GitHub release, puts it in /opt/<app> unless a target is given, and
stores the deployed version in ~/.<app>. That file is what
check_for_gh_release compares against on the next update.
| Mode | Takes | Example |
|---|---|---|
tarball (default) | The source archive of the release | fetch_and_deploy_gh_release "uptime-kuma" "louislam/uptime-kuma" "tarball" |
prebuild | A named release asset, unpacked into the target | fetch_and_deploy_gh_release "gotify" "gotify/server" "prebuild" "latest" "/opt/gotify" "gotify-linux-$(arch_resolve).zip" |
binary | A .deb asset, installed as a package | fetch_and_deploy_gh_release "miniflux" "miniflux/v2" "binary" "latest" |
singlefile | One executable asset, copied into the target and made executable | fetch_and_deploy_gh_release "argus" "release-argus/Argus" "singlefile" "0.26.3" "/opt/argus" "Argus-.*linux-amd64" (example from the helper's own header) |
arch_resolve prints amd64 or arm64 depending on the container, or the two
values you give it: arch_resolve "x86_64" "aarch64". Use it wherever an asset
name contains the architecture, otherwise the script only works on amd64.
All forge helpers are described in forge functions.
Configuration and .env
msg_info "Configuring fluid-calendar"
NEXTAUTH_SECRET="$(random_password 32)"
echo "NextAuth Secret: $NEXTAUTH_SECRET" >>~/$APPLICATION.creds
cat <<EOF >/opt/fluid-calendar/.env
DATABASE_URL="postgresql://${PG_DB_USER}:${PG_DB_PASS}@localhost:5432/${PG_DB_NAME}"
NEXTAUTH_SECRET="${NEXTAUTH_SECRET}"
...
EOF- Secrets are generated per install with
random_password <length>(letters and digits). Nothing is hardcoded. - The file is written with a heredoc.
<<EOFexpands variables; use<<'EOF'when the content must stay literal. - Where upstream ships a template, scripts copy and edit it instead.
install/bookstack-install.shdoescp .env.example .envand then replacesAPP_URL,DB_DATABASE,DB_USERNAMEandDB_PASSWORDwithsed, using$LOCAL_IPfor the URL. - Build steps run with
$STDfrom the application directory. - The
.envfile is the one thing an update has to preserve. The container script backs it up withcreate_backupbefore deploying a new release, see the update pattern.
Asking the user
An install script may need a value only the user knows. The container script
exports the variable, and the install script asks only when it is empty.
install/espocrm-install.sh:
if [[ -z "${var_admin_user:-}" ]]; then
var_admin_user=$(prompt_input "EspoCRM admin username (a-z, 0-9, @ . _ -):" "admin" 60)
fi
var_admin_user="${var_admin_user:-admin}"prompt_input <message> <default> <timeout> returns the default immediately in an
unattended run and after the timeout otherwise, so an install never hangs on a
question. prompt_confirm, prompt_password and prompt_select work the same
way. The matching line in ct/espocrm.sh is
export var_admin_user="${var_admin_user:-}".
Credentials
Generated logins go into a file in root's home directory named after the application, so the user can find them after the install output has scrolled away:
cat <<EOF >~/miniflux.creds
ADMIN_USERNAME: $ADMIN_NAME
ADMIN_PASSWORD: $ADMIN_PASS
EOF(from install/miniflux-install.sh). The database helpers append to the same
file: setup_postgresql_db and setup_mariadb_db write the database name, user
and password to ~/<app>.creds, where <app> is APPLICATION in lower case
without spaces. Fluid Calendar therefore ends up with one file,
~/fluid-calendar.creds, holding both the PostgreSQL credentials and the NextAuth
secret.
Passwords are not printed at the end of the container script.
Service
msg_info "Creating Service"
cat <<EOF >/etc/systemd/system/fluid-calendar.service
[Unit]
Description=Fluid Calendar Application
After=network.target postgresql.service
[Service]
Restart=always
WorkingDirectory=/opt/fluid-calendar
EnvironmentFile=/opt/fluid-calendar/.env
ExecStart=/usr/bin/npm run start
[Install]
WantedBy=multi-user.target
EOF
systemctl enable -q --now fluid-calendar
msg_ok "Created Service"The unit is written to /etc/systemd/system/<name>.service and enabled and
started in one step with systemctl enable -q --now. EnvironmentFile points at
the .env written above. The unit name is the one update_script stops and
starts, so keep the two in agreement.
Applications behind a web server finish this block differently:
install/babybuddy-install.sh writes /etc/nginx/sites-available/babybuddy and
calls nginx_enable_site babybuddy; install/bookstack-install.sh writes an
Apache site and runs a2ensite.
Finish
motd_ssh
customize
cleanup_lxc| Line | What it does |
|---|---|
motd_ssh | Writes the login banner (/etc/profile.d/00_lxc-details.sh and /etc/motd) with the application name, OS, hostname and IP address. If the user asked for root SSH access, installs the SSH server when missing and sets PermitRootLogin yes |
customize | When no root password was set: removes the password and configures automatic root login on the container console. Always: writes /usr/bin/update, and installs the SSH public key if one was provided |
cleanup_lxc | Removes unused packages and package caches, temporary files, and the caches of pip, uv, npm, yarn, pnpm, Go, cargo, gem and composer |
customize is where the container learns how to update itself. The file it writes
is described in Updates.
The Alpine variant
An Alpine install script has the same three parts. What changes is the
application block: apk instead of apt, and an OpenRC service instead of a
systemd unit.
install/alpine-wakapi-install.sh:
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
msg_info "Installing Dependencies"
$STD apk add --no-cache \
ca-certificates \
tzdata
$STD update-ca-certificates
msg_ok "Installed Dependencies"
fetch_and_deploy_gh_release "wakapi" "muety/wakapi" "prebuild" "latest" "/opt/wakapi" "wakapi_linux_$(arch_resolve).zip"
msg_info "Configuring Wakapi"
LOCAL_IP=$(/sbin/ip -o -4 addr list eth0 | awk '{print $4}' | cut -d/ -f1)
cd /opt/wakapi
sed -i 's/listen_ipv6: ::1/listen_ipv6: "-"/g' config.yml
sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: "0.0.0.0"/g' config.yml
sed -i "s/public_url: http:\/\/localhost:3000/public_url: http:\/\/$LOCAL_IP:3000/g" config.yml
msg_ok "Configured Wakapi"
msg_info "Enabling Wakapi Service"
cat <<EOF >/etc/init.d/wakapi
#!/sbin/openrc-run
description="Wakapi Service"
directory="/opt/wakapi"
command="/opt/wakapi/wakapi"
command_args="-config config.yml"
command_background="true"
command_user="root"
pidfile="/var/run/wakapi.pid"
depend() {
use net
}
EOF
chmod +x /etc/init.d/wakapi
$STD rc-update add wakapi default
msg_ok "Enabled Wakapi Service"
msg_info "Starting Wakapi"
$STD rc-service wakapi start
msg_ok "Started Wakapi"
motd_ssh
customize
cleanup_lxc| Debian or Ubuntu | Alpine |
|---|---|
$STD apt install -y pkg | $STD apk add --no-cache pkg |
unit in /etc/systemd/system/<name>.service | script in /etc/init.d/<name> starting with #!/sbin/openrc-run, made executable |
systemctl enable -q --now <name> | rc-update add <name> default and rc-service <name> start |
helpers from lib/tools.func | helpers from lib/alpine.func |
lib/alpine.func is smaller than lib/tools.func. It has
fetch_and_deploy_gh_release, check_for_gh_release, ensure_dependencies,
setup_uv, setup_go, setup_java, setup_composer, setup_yq and
setup_adminer, but no setup_nodejs, setup_postgresql or setup_mariadb.
Alpine scripts install those from Alpine's own packages with apk add.
The matching container script names the Alpine template with
var_os="${var_os:-alpine}" and var_version="${var_version:-3.24}", and by
convention its file name and APP start with alpine- / Alpine-.
One script, two systems
A script that supports both Debian and Alpine defines one function per family and
lets run_os_setup choose.
install/docker-install.sh
defines setup_deb_based and setup_alpine and ends with:
run_os_setup
motd_ssh
customize
cleanup_lxcrun_os_setup looks at the running system and calls setup_deb_based,
setup_alpine, setup_rhel_based, setup_suse_based, setup_arch_based or
setup_gentoo_based. If the function for the running family is missing it stops
with a message such as Docker does not support rhel: setup_rhel_based() is not defined.
Rules for new scripts
- The install script never touches the host. No
pct, noqm, no paths outside the container. - Do not fetch helper files yourself. The bootstrap loads them.
- Do not add traps or telemetry calls.
catch_errorshandles failures and the host reports the result. - Let a failing command fail. Do not hide errors with
|| trueunless the command is truly optional. - Use a helper where one exists rather than repeating its steps by hand.
Debugging
When an install fails, the host prints the failing command, its output and the
path of the combined log, then asks whether to keep the container. Inside a kept
container the install log is /root/.install-<SESSION_ID>.log. To watch an
install while it runs, start it in verbose mode (var_verbose=yes), or follow the
log from a second shell on the host:
pct exec <CTID> -- sh -c 'tail -f /root/.install-*.log'See Troubleshooting for the common failures.