Install script template
The complete template for install/appname-install.sh, with every line explained.
install/<slug>-install.sh runs inside the freshly created container, as root. The engine starts it from build_container in the ct/ script. Its name comes from APP in the ct/ script, lowercased and without spaces, followed by -install.sh.
Template
This is the template from AGENTS.md in DevScripts. It installs a Node.js application from a GitHub release; replace the middle part with what your application needs.
#!/usr/bin/env bash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: AuthorName (GitHubUsername)
# License: MIT | https://github.com/community-scripts/DevScripts/raw/main/LICENSE
# Source: https://application-url.com
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 \
dependency1 \
dependency2
msg_ok "Installed Dependencies"
NODE_VERSION="22" setup_nodejs
fetch_and_deploy_gh_release "appname" "owner/repo" "tarball"
msg_info "Setting up Application"
cd /opt/appname
$STD npm ci
$STD npm run build
msg_ok "Set up Application"
msg_info "Creating Service"
cat <<EOF >/etc/systemd/system/appname.service
[Unit]
Description=AppName Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/appname
ExecStart=/path/to/executable
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl enable -q --now appname
msg_ok "Created Service"
motd_ssh
customize
cleanup_lxcA finished script to compare with: install/journiv-install.sh in ProxmoxVE.
The fixed opening
| Line | What it does |
|---|---|
| Header | The same four comment lines as in the ct/ script. The only comments in the file. |
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" | Loads the engine's install functions. The engine prepares FUNCTIONS_FILE_PATH for the container's distribution. Never source a URL here. |
color | Sets the colour and icon variables. |
verb_ip6 | Applies the IPv6 setting chosen for the container. |
catch_errors | Installs the engine's error handling. A failing command stops the install and is reported. |
setting_up_container | Prepares the base system and checks that the container has network. |
network_check | Verifies connectivity and DNS before anything is downloaded. |
update_os | Updates the base system and loads the helper library. setup_* and fetch_and_deploy_* are available from here on. |
Seven lines, always in this order, nothing in between.
The application part
Everything between update_os and motd_ssh is yours. The usual order is dependencies, runtime and database, deploy, configuration, service.
Dependencies. One msg_info/msg_ok block, $STD apt install -y with one package per line. List only what the application needs; curl, sudo, wget, gnupg, ca-certificates, jq and mc are already there.
Runtimes and databases. Call the setup_* helper, with its settings as variables on the same line: NODE_VERSION="22" setup_nodejs, PG_VERSION="17" setup_postgresql. No msg_info around them. setup_postgresql_db creates database and user and sets PG_DB_PASS for you to use in the configuration.
Deploy. fetch_and_deploy_gh_release "appname" "owner/repo" "tarball" downloads the latest release into /opt/appname and records the version in ~/.appname. The third argument is the mode:
| Mode | For |
|---|---|
tarball | The source archive of the release |
binary | A .deb package from the release assets |
prebuild | A prebuilt archive from the release assets |
singlefile | A single binary from the release assets |
For prebuild and singlefile, pass the version ("latest"), the target directory and an asset pattern. Use $(arch_resolve) inside the pattern instead of a fixed architecture, as install/temporal-install.sh in DevScripts does:
fetch_and_deploy_gh_release "temporal" "temporalio/temporal" "prebuild" "latest" "/opt/temporal" "temporal_*_linux_$(arch_resolve).tar.gz"The name in the first argument has to be the one check_for_gh_release gets in update_script. Helper functions covers the variants for GitLab, Codeberg, tags, branches and plain URLs.
Build and configuration. Each step in its own msg_info/msg_ok pair, every noisy command prefixed with $STD. Write files with one heredoc each. Generate secrets instead of writing them down:
cat <<EOF >/opt/journiv.env
ENVIRONMENT=production
SECRET_KEY=$(openssl rand -hex 32)
DB_DRIVER=postgres
DATABASE_URL=postgresql://journiv:${PG_DB_PASS}@localhost:5432/journivPut user data and configuration outside /opt/appname, for example in /opt/appname_data and /opt/appname.env. An update empties the application directory.
Service. A systemd unit written with a heredoc, running as root, then systemctl enable -q --now appname. No systemctl daemon-reload for a new unit. Restart=on-failure is fine, but the service must not depend on it: the automated test fails a script whose service restarts on its own within a minute.
Values from the user
If the install needs a value from the user, read a var_ variable first and ask only when it is empty. From install/forgejo-runner-install.sh:
if [[ -z "${var_forgejo_runner_token:-}" ]]; then
read -r -p "${TAB3}Forgejo Runner Token: " var_forgejo_runner_token || true
fi
if [[ -z "${var_forgejo_runner_token:-}" ]]; then
msg_error "No runner registration token provided. Cannot continue."
exit 1
fiThree places have to agree on the name:
| Where | What |
|---|---|
install/<slug>-install.sh | Reads var_x, asks only when it is empty |
ct/<slug>.sh | export var_x="${var_x:-}", otherwise the value stays on the host |
json/<slug>.json | An app_vars entry named var_x, so the website can offer a field for it |
Core also has prompt_input_required, prompt_input, prompt_confirm and prompt_select, which do the check, the unattended fallback and a timeout for you:
var_admin_user=$(prompt_input_required "Admin username:" "admin" 60 "var_admin_user")Ask before the first msg_info, never inside an open block.
The fixed ending
| Line | What it does |
|---|---|
motd_ssh | Sets up the login message and the SSH settings. |
customize | Sets up the console login and creates the update command. |
cleanup_lxc | Cleans the package manager and language caches. |
Always these three, in this order, as the last lines. No own cleanup block before them.
More than one distribution
A script that supports Debian and Alpine defines one function per OS family and lets the engine pick. This is the one case where an install script defines functions:
setup_deb_based() {
msg_info "Installing AdGuard Home"
$STD apt install -y adguardhome
msg_ok "Installed AdGuard Home"
}
setup_alpine() {
msg_info "Installing AdGuard Home"
$STD apk add --no-cache adguardhome
msg_ok "Installed AdGuard Home"
}
run_os_setup
motd_ssh
customize
cleanup_lxcThat shape is from AGENTS.md; install/adguard-install.sh is a complete example. Alpine has no systemd: services are OpenRC scripts in /etc/init.d/, enabled with rc-update add and started with rc-service.