CT script template
The complete template for ct/appname.sh in DevScripts, with every line explained.
ct/<slug>.sh runs on the host. It declares the container defaults, holds the update logic, and hands over to the engine, which creates the container and runs install/<slug>-install.sh inside it. The same file is run again inside the container by the update command; there it executes update_script and nothing else.
Template
This is the template from AGENTS.md in DevScripts, with the var_arm64 line from its arm64 section added. Replace AppName, appname, owner/repo and PORT.
#!/usr/bin/env bash
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/DevScripts/main"
_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 community-scripts ORG
# Author: AuthorName (GitHubUsername)
# License: MIT | https://github.com/community-scripts/DevScripts/raw/main/LICENSE
# Source: https://application-url.com
APP="AppName"
var_tags="${var_tags:-tag1;tag2;tag3}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-8}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
#var_arm64="${var_arm64:-no}" # unset = ask the user; set yes/no only when verified
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/appname ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "appname" "owner/repo"; then
msg_info "Stopping Service"
systemctl stop appname
msg_ok "Stopped Service"
create_backup /opt/appname/.env
CLEAN_INSTALL=1 CLEAN_INSTALL_KEEP="data" fetch_and_deploy_gh_release "appname" "owner/repo" "tarball"
restore_backup
msg_info "Starting Service"
systemctl start appname
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}:PORT${CL}"A finished script to compare with: ct/journiv.sh in ProxmoxVE. It has no _CS_DEFAULT_URL line because ProxmoxVE is the engine's default.
The bootstrap
| Line | What it does |
|---|---|
_CS_DEFAULT_URL=... | Tells the engine that this script's repository is DevScripts, so the install script is fetched from there. The promotion workflow deletes this line when the script moves to ProxmoxVE. Do not write it in a ProxmoxVE script. |
_cs_boot=... | Path of a local engine: COMMUNITY_SCRIPTS_CORE_DIR if set, otherwise a core checkout next to the script repository. |
source "$_cs_boot" ... || source <(curl ...) | Loads core/build.func from that path, or from COMMUNITY_SCRIPTS_CORE_URL, or from core main. |
Copy these three lines unchanged, directly after the shebang, with no comment above or between them. Testing from a fork shows what the variables are for.
The header
Four comment lines: copyright, author with GitHub user name, licence URL, and the upstream project's address. The promotion workflow rewrites DevScripts to ProxmoxVE in the licence URL. These are the only comments in the file, apart from the reason next to var_arm64.
Declarations
Every var_ line uses the form var_x="${var_x:-default}". A value that is already set, from the command line or the settings dialog, wins; the script only supplies the default.
| Line | Meaning |
|---|---|
APP | Display name. The engine lowercases it and removes spaces to get the install script's name: APP="AppName" loads install/appname-install.sh. Choose it so that the result equals your slug; write a hyphen where the slug has one (APP="Gitea-Runner" for gitea-runner). The promotion workflow looks for install/<slug>-install.sh. |
var_tags | Tags set on the container, separated by ;. Lowercase, two or three. |
var_cpu | Default number of CPU cores. |
var_ram | Default memory in MB. |
var_disk | Default disk size in GB. |
var_os | Default distribution, lowercase: debian, ubuntu, alpine. |
var_version | Default release of that distribution, for example 13 for Debian. |
var_arm64 | arm64 support, see below. |
var_unprivileged | 1 creates an unprivileged container, 0 a privileged one. Use 0 only when the application cannot work otherwise. |
Choose resources the application really needs. The automated test measures disk and memory use after the install and notes when 90 % of var_disk or 80 % of var_ram is in use. The values must match install_methods[].resources in the JSON file.
var_arm64
The engine checks this variable on an arm64 host before it builds anything.
| State | On an arm64 host |
|---|---|
var_arm64="${var_arm64:-yes}" | Proceeds |
var_arm64="${var_arm64:-no}" | Aborts with exit code 106 |
| Line commented out | Warns that the script was never verified on arm64 and asks whether to continue; aborts when nobody can answer |
Set yes only when the script has run on arm64. Set no when the upstream project ships no arm64 build or depends on something x86-only, and say why next to it, as ct/temporal.sh in DevScripts does:
var_arm64="${var_arm64:-no}" # unset = ask the user; server, UI and CLI all ship linux_arm64 assets, but not yet run on arm64Leave the line commented out only when you cannot tell. The automated test builds such a script on arm64 with var_arm64=yes and shows the result as information.
Optional declarations
| Line | When |
|---|---|
var_gpu="${var_gpu:-yes}" | The application benefits from GPU passthrough (transcoding, AI workloads). Default is no. |
var_nesting, var_keyctl, var_fuse, var_tun | The application needs that container feature. Add only what it needs. |
var_testurl="${var_testurl:-https://...}" | The address testers report to. You do not write this line: a workflow adds it after the merge, pointing at the script's testing issue, and removes it at promotion. |
export var_myapp_x="${var_myapp_x:-}" | One per value the install script accepts from the user. Without export the variable never reaches the container. |
Application variables are explained in the install template. If one is required for an unattended install, check it here so the run fails before the container is built:
if [[ -n "${mode:-}" ]]; then
if [[ -z "${var_admin_token:-}" ]]; then
msg_error "var_admin_token is required for unattended installs."
exit 1
fi
fiA script for Debian and Alpine
An application that runs on both keeps one script and lets the user choose. The ct/ script asks only when var_os is not set, then branches the defaults. From ct/gitea-runner.sh in DevScripts:
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 3.24 (smaller)")
fi
var_os="${var_os:-debian}"
if [[ "${var_os:-}" == "alpine" ]]; then
var_ram="${var_ram:-1024}"
var_disk="${var_disk:-4}"
var_version="${var_version:-3.24}"
else
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-8}"
var_version="${var_version:-13}"
fiThe automated test recognises the msg_menu "Choose the container OS" line and builds such a script once per OS. An application that only exists for Alpine uses ct/alpine-<app>.sh and install/alpine-<app>-install.sh instead.
The four calls
header_info "$APP"
variables
color
catch_errorsAlways these four, in this order, directly after the declarations. variables derives the internal names from APP, color sets the colour and icon variables the messages use, catch_errors installs the engine's error handling. Do not add your own set -e or traps.
update_script
This is the only function a ct/ script defines. It runs inside the container when the user types update.
| Part | Rule |
|---|---|
header_info, check_container_storage, check_container_resources | Always the first three lines. |
| Installation check | Stop with msg_error and exit when the application directory or its service is missing. |
check_for_gh_release "appname" "owner/repo" | Returns success when a newer release exists. The first argument is the same name the install script passed to fetch_and_deploy_gh_release; it names the version file ~/.appname. |
| Stop, deploy, start | Stop the service, deploy the new release with the same helper and mode as the install, redo the build steps, start the service. |
CLEAN_INSTALL=1 | Empties the target directory before unpacking, so no file of the old release is left behind. |
CLEAN_INSTALL_KEEP="data" | Directories inside the target, relative to it, that survive the wipe. Needed only when user data lives inside the application directory. |
create_backup / restore_backup | Saves the named files before the deploy and puts them back after it. Needed only for files inside the application directory. |
msg_ok "Updated successfully!" | The last message of a successful update. The automated test looks for this text. |
exit | The last line of the function. |
If the application keeps its data and configuration outside its directory, as recommended, the update needs neither CLEAN_INSTALL_KEEP nor a backup. ct/journiv.sh is that case.
An application without an update path still defines update_script with the first three lines, a message and exit; set updateable to false in the JSON.
The end of the file
start
build_container
descriptionstart shows the menu on a host, or runs update_script inside a container. build_container creates the container and runs the install script. description writes the container's notes.
After that come the closing lines exactly as in the template. ${IP} is the container's address; replace PORT with the port of the web interface, or adjust scheme and path so the line shows the address a user opens. The automated test takes the first http://${IP}... or https://${IP}... address in the file and expects it to answer.
At most one more line is allowed, for a second port or the location of generated credentials. ct/temporal.sh adds one:
echo -e "${INFO}${YW}gRPC endpoint for workers and SDKs: ${IP}:7233${CL}"No conditions, no commands, no setup instructions here. What a user has to know goes into notes of the JSON file.