8.1 KiB
Component Registry
How castle tracks, configures, and manages components. This is the central
reference for castle.yaml structure and the manifest architecture.
castle.yaml
The single source of truth for all components. Lives at the repo root.
gateway:
port: 9000
components:
my-service:
description: Does something useful
run:
runner: python_uv_tool
tool: my-service
cwd: my-service
env:
MY_SERVICE_DATA_DIR: /data/castle/my-service
MY_SERVICE_PORT: "9001"
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy:
caddy: { path_prefix: /my-service }
manage:
systemd: {}
Manifest blocks
Each component declares what it does through these optional blocks:
run — How to start it
Discriminated union on runner:
| Runner | Use case | Key fields |
|---|---|---|
python_uv_tool |
Python service/tool via uv | tool, cwd, env |
command |
Shell command | argv, cwd, env |
python_module |
Python -m invocation | module, args, python |
container |
Docker/Podman | image, command, ports, volumes |
node |
Node.js script | script, package_manager (npm/pnpm/yarn) |
remote |
External service | base_url, health_url |
Services use python_uv_tool:
run:
runner: python_uv_tool
tool: my-service # name in [project.scripts]
cwd: my-service # working directory relative to repo root
env:
MY_SERVICE_DATA_DIR: /data/castle/my-service
MY_SERVICE_PORT: "9001"
Tools invoked by castle (jobs, scheduled tasks) use command:
run:
runner: command
argv: ["protonmail", "sync"]
cwd: protonmail
env:
PROTONMAIL_USERNAME: user@example.com
Standalone tools that users invoke directly often have no run block at
all — castle just installs them to PATH.
expose — What it exposes
expose:
http:
internal:
port: 9001 # Required for services
health_path: /health # Used by health polling
Having expose.http gives the component the service role.
proxy — How to proxy it
proxy:
caddy:
path_prefix: /my-service # Proxied at gateway:9000/my-service/
Castle generates a Caddyfile from these entries. Only needed for services accessible through the gateway.
manage — How to manage it
manage:
systemd: {}
Enables castle service enable/disable and castle logs. An empty {}
uses defaults (enable=true, restart=on-failure, restart_sec=5).
Full options:
manage:
systemd:
description: Custom unit description
restart: always # on-failure | always | no
restart_sec: 2
no_new_privileges: true
after: [network.target, castle-other.service]
wanted_by: [default.target]
install — How to install it
install:
path:
alias: my-tool # Command name in PATH
Creates a shim so the tool is available system-wide after
uv tool install --editable ..
tool — Tool metadata
tool:
source: components/my-tool/ # Source directory
version: "1.0.0"
system_dependencies: [pandoc, poppler-utils]
This block provides metadata for castle tool list and the dashboard.
It's separate from install (which handles PATH registration) and run
(which handles execution).
The install method (uv tool install vs symlink) is inferred from the source
directory: if pyproject.toml exists, it's a Python package; if the source
is a file, it's symlinked.
build — How to build it
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
Having build outputs gives the component the frontend role.
triggers — What triggers it
triggers:
- type: schedule
cron: "*/5 * * * *"
timezone: America/Los_Angeles # default
Having a schedule trigger gives the component the job role. Castle generates a systemd .timer file alongside the .service unit.
Other trigger types: manual, event (source + topic), request (protocol).
env with secrets
Environment variables can reference secrets stored in ~/.castle/secrets/:
run:
env:
API_KEY: ${secret:MY_API_KEY}
Castle resolves ${secret:NAME} by reading ~/.castle/secrets/NAME.
Never store secrets in castle.yaml or project directories.
Role derivation
Roles are computed from manifest declarations, never set manually:
| Role | Derived when |
|---|---|
| service | Has expose.http |
| tool | Has install.path or has tool spec (fallback) |
| worker | Has manage.systemd but no expose.http |
| job | Has trigger with type: schedule |
| frontend | Has build with outputs or commands |
| containerized | Runner is container |
| remote | Runner is remote |
A component can have multiple roles. For example, protonmail is both a
tool (installed to PATH) and a job (runs on a cron schedule).
Registering a new component
Via castle create (recommended)
# Service — scaffolds project, assigns port, registers in castle.yaml
castle create my-service --type service --description "Does something"
# Tool — scaffolds under components/
castle create my-tool --type tool --description "Does something"
Manually
Add an entry to the components: section of castle.yaml:
components:
my-tool:
description: Does something useful
tool:
source: components/my-tool/
install:
path:
alias: my-tool
Lifecycle
Service lifecycle
castle create my-service --type service # 1. Scaffold + register
cd components/my-service && uv sync # 2. Install deps
# ... implement ...
castle test my-service # 3. Run tests
castle service enable my-service # 4. Generate systemd unit, start
castle gateway reload # 5. Update Caddy routes
After service enable, the service starts automatically on boot and restarts
on failure. Manage with:
castle logs my-service -f # Tail logs
castle run my-service # Run in foreground (for debugging)
castle service disable my-service # Stop and remove systemd unit
Tool lifecycle
castle create my-tool --type tool # 1. Scaffold + register
cd components/my-tool && uv sync # 2. Install deps
# ... implement ...
castle test my-tool # 3. Run tests
uv tool install --editable components/my-tool/ # 4. Install to PATH
Job lifecycle
Jobs are tools or services with a schedule trigger. They need both run
(so castle knows how to execute them) and manage.systemd (so systemd
handles the timer):
my-job:
description: Runs nightly
run:
runner: command
argv: ["my-job"]
triggers:
- type: schedule
cron: "0 2 * * *"
manage:
systemd: {}
castle service enable my-job generates both a .service (Type=oneshot)
and a .timer file.
Infrastructure paths
| What | Where |
|---|---|
| Component registry | castle.yaml (repo root) |
| Service data | /data/castle/<name>/ |
| Secrets | ~/.castle/secrets/<NAME> |
| Generated Caddyfile | ~/.castle/generated/Caddyfile |
| Systemd units | ~/.config/systemd/user/castle-*.service |
| Systemd timers | ~/.config/systemd/user/castle-*.timer |
Manifest models
The Pydantic models live in core/src/castle_core/manifest.py. Key classes:
ComponentManifest— top-level model, hasrolescomputed propertyRunSpec— discriminated union (RunPythonUvTool, RunCommand, etc.)TriggerSpec— union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest)ExposeSpec,ProxySpec,ManageSpec,InstallSpec,ToolSpec,BuildSpecCaddySpec,SystemdSpec,HttpExposeSpec,HttpInternal
Config loading: core/src/castle_core/config.py — load_config() parses
castle.yaml into CastleConfig with typed components dict.
Infrastructure generators: core/src/castle_core/generators/ — systemd unit/timer
generation (systemd.py) and Caddyfile generation (caddyfile.py).