Anatomy of a container script
Every part of a ct/<app>.sh script as scripts are written today, annotated with real scripts.
This page walks through a container script from the first line to the last. The
examples are taken unchanged from
ProxmoxVE:
ct/gotify.sh
for the overall shape and
ct/fluid-calendar.sh
for a full update function.
The whole file
#!/usr/bin/env bash
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://gotify.net/ | Github: https://github.com/gotify/server
APP="Gotify"
var_tags="${var_tags:-notification}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-2}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_arm64="${var_arm64:-yes}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/gotify ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "gotify" "gotify/server"; then
msg_info "Stopping Service"
systemctl stop gotify
msg_ok "Stopped Service"
fetch_and_deploy_gh_release "gotify" "gotify/server" "prebuild" "latest" "/opt/gotify" "gotify-linux-$(arch_resolve).zip"
chmod +x /opt/gotify/gotify-linux-$(arch_resolve)
msg_info "Starting Service"
systemctl start gotify
msg_ok "Started Service"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW}Access it using the following URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}${CL}"(The real gotify.sh has two extra blocks in update_script that migrate an old
config file and service unit; they are left out here.)
The header
Loader
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")These two lines are identical in every script. They load
core/build.func:
first from a local checkout of the engine (COMMUNITY_SCRIPTS_CORE_DIR, or a core
directory next to the script repository), otherwise from GitHub
(COMMUNITY_SCRIPTS_CORE_URL). build.func then decides which platform it is on
and loads the menu code and the Proxmox VE or Incus backend. See
Loading and source roots.
Scripts in the testing repository carry one more line before the loader, which
tells the engine where to fetch install/<app>-install.sh from:
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/DevScripts/main"Without it the default is the ProxmoxVE repository, so ProxmoxVE scripts do not need the line.
Comment block
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://gotify.net/ | Github: https://github.com/gotify/serverCopyright holder, script author, licence and the upstream project. New scripts use
community-scripts ORG as the copyright holder.
Application defaults
APP
APP="Gotify"The display name. variables derives NSAPP from it by lowercasing and removing
spaces (Uptime Kuma becomes uptimekuma). NSAPP is used for:
- the install script that is fetched:
install/${NSAPP}-install.sh - the default hostname of the container
- the banner lookup (
headers/ct/<NSAPP>, see Headers) - the name recorded in
/usr/bin/updateand the lookup of the script's status on the website
So ct/<name>.sh, install/<name>-install.sh and APP have to agree. A script
whose file name contains a dash that APP does not can set the two derived names
itself, as
ct/netboot-xyz.sh
does right after variables:
NSAPP="netboot-xyz"
var_install="${NSAPP}-install"var_* lines
Every line has the form var_x="${var_x:-default}": the script's value only
applies when the variable is not already set. That is what lets a user override it
on the command line (var_ram=2048 bash -c "...").
| Line | Meaning | Becomes | Engine fallback |
|---|---|---|---|
var_tags | Tags for the Proxmox VE tree, separated by ; | -tags, with community-script put in front | none |
var_cpu | CPU cores | -cores | 1 |
var_ram | Memory in MiB | -memory | 1024 |
var_disk | Root disk in GB | -rootfs <storage>:<size> | 4 |
var_os | Template family: debian, ubuntu, alpine, ... | template search | debian |
var_version | OS release: 13, 24.04, 3.24 | template search | 12 |
var_unprivileged | 1 unprivileged, 0 privileged | -unprivileged | 1 |
var_arm64 | yes works on arm64, no does not, unset means never verified | arm64 check, see below | unset |
Notes on individual lines:
var_oshas to be the name used in template file names.variableslowercases it and removes spaces, with a warning (var_os was '...', which is not a template name).var_cpu,var_ram,var_diskset on the command line replace the script's values, including with lower ones. Values that come from a saved file (default.varsor an app defaults file) are different:variablesremembers the script's own numbers asAPP_DEFAULT_CPU,APP_DEFAULT_RAMandAPP_DEFAULT_DISK, and when the settings are resolved the higher of the two wins. A saved file can raise an app's resources but not lower them.var_ramis also whatcheck_container_resourcescompares against during an update, see below.var_arm64only matters on an arm64 host.yescontinues silently.nostops withThis script does not support arm64.(exit 106). Unset printsThis script has not been verified on arm64.and asksContinue anyway? (y/N); without a terminal it stops withNot running interactively - refusing to guess on arm64. Set var_arm64=yes to override.
Optional capability lines
Add these only when the application needs them. Counts are from ProxmoxVE ct/
at the time of writing.
| Line | Used by | Effect on Proxmox VE |
|---|---|---|
var_gpu="${var_gpu:-yes}" | 41 scripts, e.g. ct/frigate.sh, ct/emby.sh | GPU device nodes of the host are added to the container config |
var_tun="${var_tun:-yes}" | 6 scripts, e.g. ct/gluetun.sh | /dev/net/tun is allowed and bind-mounted |
var_fuse="${var_fuse:-yes}" | 4 scripts, e.g. ct/cosmos.sh | feature fuse=1 |
var_nesting="${var_nesting:-0}" | 2 scripts, e.g. ct/alpine-cinny.sh | 0 leaves out feature nesting=1, which is on by default |
var_keyctl="${var_keyctl:-1}" | 2 scripts, ct/github-runner.sh and ct/forgejo-runner.sh | Shown in the advanced settings. On Proxmox VE keyctl=1 is set for every unprivileged container regardless |
What each of these turns into is described in Container creation.
Scripts in the testing repository also set var_testurl to the issue or
discussion that collects feedback. It adds the testing tag and a notice in the
container notes and login banner.
Application settings
A value that only the install script needs has to be exported, because only
exported variables reach the install script.
ct/docker.sh:
# Only exported variables reach the install through lxc-attach.
export var_docker_socket="${var_docker_socket:-}"
export var_docker_compose="${var_docker_compose:-}"
export var_docker_lxcfs="${var_docker_lxcfs:-}"The install script reads the variable first and asks only when it is empty.
Choosing the OS at run time
A script that supports two base systems asks on the host only, then sets its
resource defaults. ct/docker.sh:
if [[ -z "${var_os:-}" ]] && command -v pveversion >/dev/null 2>&1; then
var_os=$(msg_menu "Choose the container OS" \
"debian" "Debian 13" \
"alpine" "Alpine (smaller footprint)")
fi
if [[ "${var_os:-}" == "alpine" ]]; then
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-1024}"
var_disk="${var_disk:-2}"
var_version="${var_version:-3.24}"
else
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-4}"
var_version="${var_version:-13}"
fiInside a container core/build.func has already set var_os and var_version
from /etc/os-release, so on an update the branch that matches the running system
is taken without a question.
The four setup calls
header_info "$APP"
variables
color
catch_errors| Call | What it does |
|---|---|
header_info "$APP" | Clears the screen and prints the application banner. If no banner exists yet it prints a generic "Community Scripts" banner and the app name |
variables | Sets NSAPP, var_install, a random SESSION_ID, the host log path BUILD_LOG (/tmp/create-lxc-<SESSION_ID>.log), reads dev_mode, and records APP_DEFAULT_CPU/RAM/DISK |
color | Sets the colour variables (YW, GN, RD, CL, ...) used by all messages. With dev_mode set, green becomes red |
catch_errors | Turns on set -Ee -o pipefail and installs the traps: ERR to error_handler, EXIT, INT, TERM, HUP |
variables has to come after the var_* lines, because it reads them, and before
everything that uses NSAPP.
update_script
update_script runs inside the container, as root, when the user types update.
The engine calls it from start; the lines below start never run in that case.
Structure
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/fluid-calendar ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
# 1. prerequisites
# 2. version check
# 3. stop, back up, deploy, restore, rebuild, start
exit
}| Line | What it does |
|---|---|
header_info | Clears the screen and prints the banner again |
check_container_storage | Reads the usage of /. Above 80 % it prints Warning: Storage is dangerously low (<n>%). and asks Continue anyway?. Declining, or having nobody to ask, exits with 114 |
check_container_resources | Compares the container's RAM and CPU count with var_ram and var_cpu. If either is lower it prints Required: ... | Current: ... and asks Continue update with under-provisioned LXC?. Declining exits with 113 |
| installation check | Stops with No ${APP} Installation Found! when the application directory is missing, for example when the script is run in the wrong container |
exit | Ends the script. Every path through update_script ends with exit |
check_container_resources is why var_cpu and var_ram matter after the
install too: a build step that needs 4 GB at install time needs it again on every
update.
A few scripts leave exit out at the end, for example ct/docker.sh. The engine
then offers to update installed add-ons and exits itself. A script must not
return early expecting to stop there, though: after update_script returns the
add-on prompt still runs.
Version check
if check_for_gh_release "fluid-calendar" "dotnetfactory/fluid-calendar"; then
...
ficheck_for_gh_release <app> <owner/repo> compares the latest GitHub release with
the version stored in ~/.<app> by the last deploy. It prints either
Update available: fluid-calendar 1.2.0 -> 1.3.0 and returns 0, or
No update available: fluid-calendar (1.3.0) and returns 1. All update work goes
inside the if, so an up-to-date container is left alone.
Stop, back up, deploy, restore, start
The body of the if in
ct/fluid-calendar.sh:
ensure_dependencies build-essential
NODE_VERSION="24" setup_nodejs
if check_for_gh_release "fluid-calendar" "dotnetfactory/fluid-calendar"; then
msg_info "Stopping Service"
systemctl stop fluid-calendar
msg_ok "Stopped Service"
create_backup /opt/fluid-calendar/.env
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "fluid-calendar" "dotnetfactory/fluid-calendar" "tarball"
restore_backup
msg_info "Updating Fluid Calendar"
cd /opt/fluid-calendar
export NEXT_TELEMETRY_DISABLED=1
$STD npm install --legacy-peer-deps
$STD npm run prisma:generate
$STD npx prisma migrate deploy
$STD npm run build:os
msg_ok "Updated Fluid Calendar"
msg_info "Starting Service"
systemctl start fluid-calendar
msg_ok "Started Service"
msg_ok "Updated successfully!"
fi
exit| Step | Line | Why |
|---|---|---|
| Prerequisites | ensure_dependencies, NODE_VERSION="24" setup_nodejs | Runs before the version check so the toolchain is brought to the version the script now expects even when the app itself is current |
| Stop | systemctl stop fluid-calendar | Nothing writes to the files while they are replaced |
| Back up | create_backup /opt/fluid-calendar/.env | Copies the listed files and directories to /opt/<NSAPP>.backup and records them in a manifest |
| Deploy | CLEAN_INSTALL=1 fetch_and_deploy_gh_release ... | Downloads the release into /opt/fluid-calendar. With CLEAN_INSTALL=1 the directory is wiped once the download has extracted, dotfiles such as .env included, so files removed upstream do not linger |
| Restore | restore_backup | Copies everything from the manifest back and deletes the backup store |
| Rebuild | npm install, migrations, build | The same build steps as in the install script |
| Start | systemctl start fluid-calendar |
create_backup stops the update if a copy fails, and it keeps an existing backup
from an earlier failed run instead of overwriting it with half-updated data. It
takes any number of paths, as
ct/bookstack.sh
shows:
create_backup /opt/bookstack/.env \
/opt/bookstack/public/uploads \
/opt/bookstack/storage/uploads \
/opt/bookstack/themes$STD in front of a command hides its output unless the user chose verbose mode;
the output always goes to the log. msg_info starts a spinner line and msg_ok
closes it. The helpers fetch_and_deploy_gh_release, setup_nodejs and
create_backup print their own messages, so they are not wrapped in
msg_info/msg_ok.
Other shapes
- Packages only.
ct/apt-cacher-ng.shhas no release to check and runsapt_update_safeand$STD apt -y upgrade. - Alpine.
ct/alpine-wakapi.shfollows the same pattern withrc-service wakapi stop,$STD apk -U upgradeandrc-service wakapi start. - Two base systems.
ct/docker.shdefinesupdate_deb_basedandupdate_alpineand letsrun_os_updatecall the one that matches the running system.
What the engine does around it
Before update_script is called the engine checks whether the script was
disabled or removed and whether a breaking change is announced. When the update
ends with exit code 0, the engine's exit trap runs cleanup_lxc (package and
language caches) and refreshes the IP address in the login banner. None of that
belongs in update_script. The details are on the
Updates page.
start, build_container, description
start
build_container
descriptionstart decides what kind of run this is.
- On a Proxmox VE host it checks whether the script is disabled or removed, then runs the host checks (Bash, root, Proxmox VE version, architecture, kernel key limits, storage content types) and shows the install menu. When it returns, all settings are resolved. The menu and the settings are described in Settings and the wizard.
- Inside a container it asks
YES (Silent Mode),YES (Verbose Mode)orNO (Cancel Update), callsupdate_scriptand exits. Without a terminal, or withPHS_SILENT=1, it updates silently without asking.
build_container creates the container with pct create, applies features
and passthrough, starts it, waits for the network, installs a few base packages
and runs install/<NSAPP>-install.sh inside it. If that fails it offers to remove
or keep the container. See Container creation.
description runs after a successful install. It reads the container's IP
address into IP, writes the notes shown in the Proxmox VE interface (logo, links
to the script page, GitHub, discussions and issues), runs the optional
var_post_install hook on the host, and reports the run as done.
Closing output
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW}Access it using the following URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}:3000${CL}"The last lines tell the user where the application is. IP was set by
description. Use the scheme and port the application really listens on; a script
without a web interface, such as ct/alpine.sh, ends after the first line.
Generated credentials are not printed here. The install script writes them to a
file in root's home directory, see
Install scripts.
Rules for new scripts
- Keep the script linear: declare, call the engine, write
update_script. Apart fromupdate_scriptand theupdate_<family>functions thatrun_os_updatecalls, do not define helper functions; shared logic belongs in the engine. - Do not call
pct,qmorincusin a container script. The backend does that. - Do not download other
*.funcfiles.core/build.funcloads what is needed. - Do not add your own traps or telemetry calls.
catch_errorsanddescriptioncover both. - Every
var_*line uses the${var_x:-default}form. A plain assignment silently discards what the user passed in.
Debugging
To keep a container whose install failed, and to see every command:
dev_mode=keep,trace bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gotify.sh)"All flags are listed in Dev mode. To run a script from a fork or a branch, see Script origin.