A stacklet is a self-contained service unit. It's a directory with a manifest, a Docker Compose file, and optional hooks.
stacklets/myapp/
stacklet.toml # manifest — required
docker-compose.yml # Docker services — required for Docker stacklets
That's it. Two files and stack up myapp works.
stacklets/ holds the stacklets famstack ships. Everything else goes in
~/famstack-extensions/, or wherever [core] extension_dirs points:
~/famstack-extensions/myapp/
stacklet.toml
docker-compose.yml
Nothing creates that directory. Make it when you have something to put
in it, then stack up myapp.
Discovery, ids, hooks, CLI plugins, secrets and template variables are
the same code path in both places, so stack up myapp does not care
which you picked. Being outside the repo means an upgrade cannot take
your stacklet with it, git status never mentions it, and it can be its
own git repo. Nothing famstack does backs it up, though: it is source
code, so push it somewhere or include it in your own backup.
extension_dirs is a list, so you can keep a dev checkout beside the
default directory. It is searched in order and the first tree to claim an
id keeps it, so the repo wins a clash with an extension and an earlier
directory wins a clash with a later one.
A stacklet here can ship a bot, because the bot runner mounts the first
extension directory next to the repo one. It mounts only that one, so a
bot in a later directory is not discovered and stack up tells you.
stage = "incubating" # or "beta"stack list marks the stacklet and every stack up warns that it can
change or disappear and is not for production. Delete the line (or set
"stable") when that stops being true.
id = "myapp"
name = "My App"
description = "What it does in one line"
version = "0.1.0"
category = "productivity" # productivity, communication, ai, etc.
port = 42070 # primary web UI port
hints = [
"Open {url} in your browser",
"Log in as {admin_username} / {admin_password}",
]Values in {braces} are resolved from stack.toml and runtime state:
| Variable | Source |
|---|---|
{language} |
[core].language (falls back to [ai].language, then en) |
{timezone} |
[core].timezone |
{data_dir} |
[core].data_dir |
{domain} |
[core].domain |
{ip} |
LAN IP of the host |
{url} |
Public URL for this stacklet |
{admin_username} |
Tech admin username (stackadmin) |
{admin_email} |
Tech admin email (stackadmin@home.local) |
{admin_password} |
Tech admin password (generated, in secrets) |
{myapp_url} |
URL of any stacklet by ID |
{myapp_synapse_url} |
Named port URL ([ports].synapse) |
[env]
generate = ["DB_PASSWORD", "SECRET_KEY"] # auto-generated, stored in secrets
[env.defaults]
DATA_DIR = "{data_dir}/myapp"
TZ = "{timezone}"
ADMIN_USER = "{admin_email}"
ADMIN_PASSWORD = "{admin_password}"generate keys are random 32-char strings, created once, stable across restarts. defaults are rendered from template variables every time stack up runs.
# Simple — single URL
[health]
url = "http://localhost:42070/api/health"
# Multiple with failure hints
[[health.checks]]
url = "http://localhost:42070/"
hint = "Web UI not responding — check 'docker logs stack-myapp'"
[[health.checks]]
url = "http://localhost:42071/api"
hint = "API not responding"
# With auth headers (template vars supported)
[[health.checks]]
url = "{some_url}/status"
hint = "Backend not reachable"
[health.checks.headers]
Authorization = "Bearer {some_key}"Health checks run during stack list to determine online/degraded state.
requires = ["messages"] # must be set up before this stackletstack up myapp will refuse if dependencies aren't set up, with a helpful error message.
Follow these conventions:
name: stack-myapp # project name: stack-{id}
services:
stack-myapp: # container name: stack-{id}
container_name: stack-myapp
image: someimage:1.4 # minor line, else the exact version
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
networks:
- stack # shared network
ports:
- "${PORT_BIND_IP:-0.0.0.0}:42070:8080" # use PORT_BIND_IP
volumes:
- ${DATA_DIR}:/data # use env vars from [env.defaults]
env_file: .env # framework writes this
restart: unless-stopped
networks:
stack:
external: trueKey points:
- Project name:
stack-{id}— framework uses this to track container state - Network:
stack(external) — all stacklets share it, can talk to each other - Ports: Use
PORT_BIND_IPso port binding works in both dev and production - env_file:
.env— the framework renders this from your[env.defaults] - Volumes: Use env vars, not hardcoded paths
Optional lifecycle scripts in a hooks/ directory:
stacklets/myapp/
hooks/
on_configure.py # first-run config (interactive prompts)
on_install.sh # first-run setup (install dependencies)
on_start.py # every stack up, before compose (config validation)
on_install_success.py # after first successful health check
on_start_ready.py # every stack up, after health checks (seed data, sync accounts)
on_stop.sh # every stack down
on_destroy.sh # full teardown
Python hooks (preferred) get a ctx object:
def run(ctx):
ctx.step("Creating admin account") # report progress
ctx.shell("some-command") # run shell command
ctx.secret("API_KEY") # read a secret
ctx.secret("API_KEY", "value") # write a secret
ctx.stack.run_cli_command("ai", "hello") # call other stacklets
env = ctx.env # rendered env vars
stack = ctx.stack # full Stack instanceShell hooks (.sh fallback) get env vars and stream output directly.
For interactive prompts in hooks, use the shared TUI primitives:
from stack.prompt import section, out, nl, ask, confirm, done
def run(ctx):
section("My App", "Configure your widget")
name = ask("Widget name")
if not confirm(f"Use '{name}'?"):
raise RuntimeError("Aborted")
done(f"Widget configured: {name}")Add commands under cli/:
stacklets/myapp/
cli/
status.py # stack myapp status
migrate.py # stack myapp migrate
Each plugin defines a run(args, stacklet, config) function:
HELP = "Show widget status"
def run(args, stacklet, config):
data_dir = config["data_dir"]
# ... do work
return {"status": "ok"}Famstack uses the 42000-42099 range:
| Port | Stacklet |
|---|---|
| 42010 | photos |
| 42020 | docs |
| 42030-42031 | messages (element, synapse) |
| 42040 | code |
| 42050 | chatai |
| 42060-42063 | ai (omlx, lmstudio, whisper, speech) |
Pick an unused port in this range.
The simplest real stacklet — Forgejo with SQLite, no hooks, no secrets:
stacklets/code/stacklet.toml
id = "code"
name = "Code"
description = "Self-hosted Git server (Forgejo)"
version = "0.1.0"
category = "productivity"
port = 42040
hints = [
"Open {url} and register your admin account",
"SSH clone: ssh://git@{ip}:222/user/repo.git",
]
[env.defaults]
CODE_DATA_DIR = "{data_dir}/code"
TZ = "{timezone}"
[health]
url = "http://localhost:42040/api/healthz"stacklets/code/docker-compose.yml
name: stack-code
services:
stack-code:
container_name: stack-code
image: codeberg.org/forgejo/forgejo:14.0
networks:
- stack
ports:
- "${PORT_BIND_IP:-0.0.0.0}:42040:3000"
- "${PORT_BIND_IP:-0.0.0.0}:222:22"
volumes:
- ${CODE_DATA_DIR}:/data
- /etc/localtime:/etc/localtime:ro
env_file: .env
restart: unless-stopped
networks:
stack:
external: trueThat's 20 lines of TOML + 17 lines of YAML. Run stack up code and you have a Git server.