asterisk.sh and asterisk-digital-ocean.sh now offer, at the end of both their fresh-install and update-mode paths, to also set up the Security Dashboard and configure a real PSTN trunk in the same run - one script walks through the whole stack instead of needing to separately remember and run `sudo ./setup.sh security-dashboard` / `sudo ./setup.sh pstn-trunk` afterward. Both target services keep their own register_service call and stay fully independently invocable - this is purely an additive convenience layer (_asterisk_offer_dashboard_and_trunk / _asterisk_do_offer_dashboard_and_trunk), not a replacement. An already-installed piece is silently refreshed (install_security-dashboard/install_pstn-trunk each already have their own update/fresh/cancel reinstall-mode gate, so calling them again just does the right thing); a not-yet-installed piece gets one y/n instead of every detailed prompt firing. Guarded with declare -F so a standalone `sudo bash asterisk.sh` copy (no sibling services/*.sh files sourced) skips both cleanly with an explanatory message instead of erroring on an undefined function. Verified: full sourcing simulation resolves all four install_* functions correctly, `setup.sh --dry-run --unattended asterisk` and `asterisk-digital-ocean` both complete cleanly end-to-end, and `setup.sh --list` still shows all four services as independently selectable. Documented the pattern in CLAUDE.md under a new "Chaining into another service from within your own" section for future contributors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
21 KiB
CLAUDE.md — ubuntu-post-install contributor guide
Context for adding or modifying services. Read this before touching any service file so the result matches what's already here.
How the system works
setup.sh sources lib/common.sh then globs every services/*.sh file.
Each service file self-registers and defines its install function. Nothing
in setup.sh needs to change when you add a service — just add the file.
The wizard groups services by category (from register_service), shows a
checklist per group, and calls install_<name>() for each selected item.
--list, --dry-run, and --unattended all work automatically.
Adding a service — the three-step rule
- Create
services/<name>.sh(kebab-case filename) - Call
register_serviceat the top of the file - Define
install_<name>()— keep hyphens literal in the function name (install_asterisk-digital-ocean, notinstall_asterisk_digital_ocean).setup.sh's dispatcher callsinstall_${name}with no hyphen→underscore conversion, so the function name must match the service name exactly. Confirmed live: a mismatched underscore here producesService 'x' has no install_xat runtime, not a load-time error.
That's it. The menu picks it up on the next run.
Also update the Services table in README.md — add the service name to the
appropriate group row so the README stays current.
Minimal Docker service template
#!/bin/bash
# services/my-tool.sh — One-line description.
# Part of the modular post-install system (sourced by setup.sh).
register_service my-tool utilities "What it does (My Tool)" 8080
install_my_tool() {
require_docker || return 1
local DIR="$DOCKER_DIR/my-tool"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
return 0
fi
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
cat > docker-compose.yml << 'EOF'
name: my-tool
services:
my-tool:
image: vendor/my-tool:latest
container_name: my-tool
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
EOF
configure_caddy_for_service "My Tool" "8080" "my-tool"
write_readme "$DIR" << 'MD'
# My Tool
Brief description.
## Manage
```bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
MD
local START=""
prompt_yn "Start My Tool now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "My Tool started" \
|| log_warning "Start failed — check: docker compose logs"
fi
}
## register_service signature
```bash
register_service <name> <group> "<description>" [port]
name— kebab-case, matches the filename and theinstall_functiongroup— one of the categories below; determines which menu it appears indescription— shown in--listand the menu checklistport— optional; informational only (not used by the framework)
Available globals
| Variable | Value |
|---|---|
DOCKER_DIR |
~/docker — parent for all Docker service directories |
ACTUAL_USER |
The non-root user that invoked sudo |
ACTUAL_HOME |
Home directory of ACTUAL_USER |
SITE_TZ |
Timezone from site config, e.g. America/New_York |
SITE_DOMAIN |
Base domain from site config, e.g. example.com |
SITE_CADDY_NET |
Docker network name for Caddy (default: caddy_net) |
DRY_RUN |
true/false — set by --dry-run flag |
UNATTENDED |
true/false — set by --unattended flag |
Available helpers (lib/common.sh)
Logging
log_info "message" # blue [INFO]
log_success "message" # green [OK]
log_warning "message" # yellow [WARN]
log_error "message" # red [ERROR]
Prompts — honor UNATTENDED automatically
prompt_yn "Question? (y/n):" "default_y_or_n" VARNAME
prompt_text "Question? [default]:" "default" VARNAME
prompt_reinstall_mode VARNAME # sets VARNAME to: update | fresh | cancel
When UNATTENDED=true all three skip the prompt; prompt_yn/prompt_text use
their given default, prompt_reinstall_mode always resolves to cancel. See
Update vs. fresh reinstall on rerun below for how to use the latter.
Pre-flight
require_root # exits with an error if not running as root
require_docker # installs Docker CE + Compose plugin if missing, then returns
Execution and ownership
run_cmd COMMAND [args...] # no-ops in DRY_RUN, executes otherwise
ensure_docker_dir_ownership DIR... # chown -R ACTUAL_USER:ACTUAL_USER (skips in DRY_RUN)
generate_password [length] # alphanumeric random string, default 32 chars
pip_user_install PACKAGE... # pip3 --user with --break-system-packages on 24.04+
Caddy reverse proxy
configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"]
Prompts the user for a domain, appends a site block to the Caddyfile, and
reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument
is an optional string inserted verbatim inside the Caddy site block (use it
for import authelia or custom matchers).
The function places that block before reverse_proxy in the generated
site block — don't reorder this. forward_auth (what import authelia
expands to) is the same directive family as reverse_proxy internally, and
Caddy doesn't reorder repeats of the same directive within a block; it runs
them in the order they're written. reverse_proxy written first would
handle and terminate every request immediately, making an auth check
written after it dead code that never runs — full bypass regardless of what
the auth server's own access-control rules say. Confirmed live: this was
the actual cause of a "Caddy proxies fine but Authelia never prompts for
login" bug, on a site block that otherwise looked completely correct. If a
service builds its own site block instead of using this helper (e.g.
services/asterisk-digital-ocean.sh does, deliberately, see its own
comment for why), put its auth block first there too.
forward_auth to a remote Authelia over a scheme-qualified URL needs
explicit header_up pins. A bare forward_auth authelia:9091 (Authelia on
the same Docker network, one hop) is fine relying on Caddy's default
X-Forwarded-* headers. But forward_auth https://auth.example.com { ... }
(Authelia on a different machine, reached over its own public domain+TLS —
see services/asterisk-digital-ocean.sh's remote-Authelia prompt) is a
second Caddy hop: Caddy rewrites the outgoing request's Host header to
auth.example.com so the remote Caddy can route/SNI-match it, and without an
override X-Forwarded-Host picks up that rewritten value instead of the
original site's host. Confirmed live: Authelia evaluated every protected
domain as if the request were for auth.example.com itself (which typically
has policy: bypass in access_control.rules so its own login portal isn't
gated behind itself) — so every domain behind the remote instance silently
passed through with no 2FA prompt, regardless of that domain's own policy.
Fix: pin the forwarded headers to the original request explicitly instead of
trusting Caddy's default derivation:
forward_auth https://auth.example.com {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}
This only affects the remote-Authelia path — same-machine authelia:9091
snippets (services/authelia.sh) are a single hop and don't need it.
Sets two out-params (not local — read them after the call returns) so the
caller can tell whether Caddy actually ended up fronting the service:
CADDY_SERVICE_CONFIGURED # true/false
CADDY_SERVICE_MODE # "local" or "remote" (only meaningful if configured)
Use this to skip opening a host firewall port for a service Caddy already
fronts locally (it reaches the service over host.docker.internal, not
the network) — but still open it when CADDY_SERVICE_MODE is "remote",
since a remote Caddy machine needs to reach this host over the network
instead. See services/asterisk.sh and services/asterisk-digital-ocean.sh
for the reference pattern: call configure_caddy_for_service before
building firewall rules, not after, so the decision is known in time.
UFW enable
ensure_ufw_enabled
Call this after your service has already added its own ufw allow
rules — it only flips UFW from inactive to active, it doesn't add rules for
you. No-ops if UFW is already active or not installed. Always allows SSH
first (reading the real port from sshd_config in case it's non-default)
before enabling, so this can't lock out the session running the installer.
Closing a port to the internet without also closing it to Caddy
ufw_allow_from_caddy_net PORT [PROTO] # PROTO defaults to tcp
When CADDY_SERVICE_MODE is "local" (see above) and you ufw delete allow a port because Caddy fronts it now, don't stop there — UFW rules
apply to all interfaces unless scoped, and Caddy's own request to
host.docker.internal:PORT is ordinary INPUT-chain traffic arriving over
the caddy_net bridge, not the public internet. A bare ufw delete allow
blocks that too and silently breaks the service (confirmed live: closing
the web admin port outright took Caddy down with it). Call
ufw_allow_from_caddy_net right after the delete to re-open the port
scoped to just caddy_net's subnet — reachable from Caddy, not from the
internet. See services/asterisk-digital-ocean.sh and
services/asterisk.sh for the pattern.
README generation
write_readme "$DIR" << 'MD'
# Title
Content
MD
Writes $DIR/README.md (creates the directory if needed). No-ops in DRY_RUN.
Every Docker service should call this so ~/docker/<name>/README.md is
self-documenting on the deployed box.
Categories
| Group | Purpose |
|---|---|
base |
CLI packages installed on every box |
homelab |
Core infrastructure — reverse proxy, auth, intrusion prevention |
utilities |
Self-hosted web apps — budget, DNS, files, monitoring, VPN, etc. |
media |
Media servers, photo backup, disc ripping |
cameras |
NVR and camera tooling (Frigate) |
gaming |
Game servers, cloud gaming (Wolf), emulation |
extras |
Non-Docker tools and scripts |
backup |
Backup solutions |
Authelia SSO — which services need it
Some services have their own login screens; others have none and need Caddy to gate them via Authelia.
Protecting more than one apex domain from the same box — same instance, not a second
one. services/authelia.sh, re-run against an existing install, offers "Add another
protected domain to this instance": it appends a new access_control.rules entry and a new
session.cookies entry (both are YAML lists — Authelia natively supports multiple
independent cookie scopes) plus a Caddy auth.<domain> portal block for the new domain, all
on the same Authelia + Redis container. Each domain gets its own login/session (no
cross-domain SSO between them) and shares one user database, without the RAM cost of a
second full Authelia+Redis stack — the right choice whenever the domains are going to live
on the same machine anyway. See add_authelia_domain() in services/authelia.sh.
Running a genuinely separate instance (e.g. one per machine). services/authelia.sh
runs standalone on any box (sudo bash authelia.sh, same pattern as crowdsec.sh) and
asterisk-digital-ocean.sh already auto-detects a local install (if [ -d "$DOCKER_DIR/authelia" ]), switching from the remote-Authelia forward_auth flow to the
local import authelia snippet automatically — so a second, fully independent instance on
another machine (e.g. a droplet, for resilience if the first machine goes down) works with
no code changes. Use this instead of the same-instance approach above when the two domains
are on different machines, not just different domains on one machine.
The one real constraint for genuinely separate instances: Authelia's session cookie is
scoped to AUTHELIA_DOMAIN (the apex domain entered at install time) with
includeSubDomains-style matching, and the portal itself lives at auth.${AUTHELIA_DOMAIN}.
Two independent instances must not share the same AUTHELIA_DOMAIN. If they did, both
would try to claim the same auth.<domain> hostname (DNS can only point that at one
machine) and the same cookie scope with completely separate session stores — users bouncing
between subdomains fronted by different instances would see confusing repeated logins as
each instance's cookie gets overwritten/rejected by the other's. Give each instance either a
genuinely separate apex domain, or a distinct subdomain tree the other instance doesn't also
claim. (This constraint doesn't apply to the same-instance, multiple-domains approach above —
each domain there gets its own cookie entry by design, which is exactly what avoids the
collision.)
Has built-in auth — no Authelia needed:
emby, jellyfin, audiobookshelf, immich, mealie, actualbudget,
homeassistant, portainer, meshcentral, traccar, uptimekuma,
filebrowser, wg-easy, ntfy (configurable)
No built-in auth — should be protected:
magicmirror, wolf-pair, js99er, sky-cam
For services without built-in auth, prompt the user before calling
configure_caddy_for_service and pass import authelia as the extra block
if Authelia is installed and the user wants SSO protection:
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"
Authelia "stay logged in" / kiosk mode:
Edit ~/docker/authelia/config/configuration.yml and set a long
remember_me_duration. Users then check "Remember me" once on login and
the session persists through reboots (Redis stores the session in a volume):
session:
secret: 'your-existing-secret'
remember_me_duration: 1y # add or update this line
expiration: 1h
inactivity: 5m
cookies:
- domain: 'example.com'
authelia_url: 'https://auth.example.com'
After editing: docker compose -f ~/docker/authelia/docker-compose.yml restart
Non-Docker services
Not everything is a container. For apt-based or git-clone–based services,
skip require_docker and the Docker helpers. See services/base.sh (apt
packages + Charm repo) and services/crowdsec.sh (official apt repo) as
reference patterns.
For non-Docker services the default is_installed check in setup.sh
looks for $DOCKER_DIR/$name, which won't exist. Add a case to the
is_installed() function in setup.sh so the [installed] marker appears
correctly in the menu:
# In setup.sh → is_installed()
my-tool) command -v my-tool >/dev/null 2>&1 ;;
Docker services use the default case and don't need an entry.
DRY_RUN convention
Every install_* function must check $DRY_RUN before touching the
filesystem, installing packages, or starting containers. The pattern is:
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would do X"
echo "[DRY-RUN] Would do Y"
return 0
fi
Put the check early — after any pure-display output (banners, info text) but before the first write.
Update vs. fresh reinstall on rerun
Every service should detect an existing install at the top of its
install_<name>() — after the DRY_RUN check, before any prompts — and
offer prompt_reinstall_mode instead of silently re-running every prompt
(domain, secrets, firewall, Authelia, extras...) from scratch. What counts
as "already installed" is service-specific: usually docker-compose.yml and
.env both existing in the service's $DOCKER_DIR/<name> directory.
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
# Refresh vendor files / config templates, rebuild, done.
# Do NOT touch .env, firewall rules, or Caddy/Authelia config.
...
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;; # fall through to the full install flow below
esac
fi
update should be genuinely non-destructive: refresh whatever the service
vendors or templates (Docker image sources, config templates,
docker-compose.yml) and rebuild/restart, but never touch .env, firewall
rules, or reverse-proxy/SSO config that's already in place. If the
vendor-copy or docker-compose.yml-generation logic is more than a few
lines, factor it into a helper function so the fresh-install path and the
update path share one copy instead of drifting apart — see
_asterisk_do_refresh_vendor_files/_asterisk_do_write_compose in
services/asterisk-digital-ocean.sh (and their _asterisk_* counterparts in
services/asterisk.sh) for the reference pattern.
cancel must leave the install completely untouched — it's the default for
a reason (a stray Enter on a service you're just checking on shouldn't
trigger anything). fresh runs the exact same flow a first-time install
would, prompts included.
Chaining into another service from within your own
A service can call another service's install_<name>() directly as a
convenience step at the end of its own flow, instead of making the user
remember to separately run sudo ./setup.sh <other-name> afterward.
services/asterisk.sh/services/asterisk-digital-ocean.sh do this for
services/security-dashboard.sh and services/pstn-trunk.sh — after
Asterisk itself is installed/updated, each asks once whether to also set up
the dashboard and/or a PSTN trunk (or, if either is already installed,
silently re-invokes it so it gets refreshed as part of the same run — its
own prompt_reinstall_mode gate decides update vs. skip, so this never
re-asks the target service's detailed prompts unless the user is actually
setting it up fresh).
The target service keeps its own register_service call — it stays
independently selectable/invocable exactly as before (sudo ./setup.sh pstn-trunk still works standalone). Chaining is purely additive, not a
replacement for the target's own entry point, so nothing breaks for anyone
already relying on running it directly.
Guard every cross-file call with declare -F, since a service can also run
completely standalone (sudo bash asterisk.sh, no setup.sh, no sibling
services/*.sh files sourced at all):
if declare -F install_security-dashboard >/dev/null 2>&1; then
install_security-dashboard
fi
Only chain in one direction, and only when the relationship is genuinely
one-way (the target is meaningless without the caller already installed —
pstn-trunk.sh itself says so in its own error message when Asterisk isn't
present). Don't have both sides call each other.
.env files and secrets
Generate passwords with generate_password (never hardcode them).
Write secrets to .env files in the service directory, owned by
ACTUAL_USER, permissions 600. Document every variable with a comment
in the .env heredoc so the user knows what to change later.
Caddy network wiring
Services that need to reach Caddy (or each other) over Docker networking
should join the $SITE_CADDY_NET network. Add to docker-compose.yml:
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
And read the network name from .env using CADDY_NET=$SITE_CADDY_NET.
external: true means this service expects the network to already exist —
it doesn't create it. require_docker creates it for you (via
ensure_caddy_network in lib/common.sh) the first time any service calls
it, so as long as your install_<name>() calls require_docker before
docker compose up (it always should), the network is guaranteed to exist
regardless of whether Caddy itself has been installed yet.
network_mode: host services (e.g. asterisk/asterisk-digital-ocean) don't join
caddy_net at all — Caddy reaching them (or anything else on the host
network) needs host.docker.internal:PORT in the Caddyfile, not
localhost:PORT or a container name. Caddy's own compose file
(services/caddy.sh) sets extra_hosts: host.docker.internal:host-gateway
so that hostname resolves; configure_caddy_for_service's bare-port upstream
case already does this for you — don't hand-roll localhost:PORT in a
Caddy site block.