12 KiB
Registry
How castle tracks, configures, and manages programs, services, and jobs.
This is the central reference for castle.yaml structure and the registry
architecture.
castle.yaml
The single source of truth. Lives at ~/.castle/castle.yaml. Three top-level
sections:
gateway:
port: 9000
programs:
my-tool:
description: Does something useful
source: code/my-tool
stack: python-cli
behavior: tool
system_dependencies: [pandoc]
services:
my-service:
component: my-service
run:
runner: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy:
caddy: { path_prefix: /my-service }
manage:
systemd: {}
jobs:
my-job:
component: my-tool
run:
runner: command
argv: [my-tool, sync]
schedule: "0 2 * * *"
manage:
systemd: {}
Section semantics
| Section | Purpose | Category |
|---|---|---|
programs: |
Software catalog — what exists | tool, frontend, daemon |
services: |
Long-running daemons — how they run | service |
jobs: |
Scheduled tasks — when they run | job |
Services and jobs can reference a program via component: for description
fallthrough and source code linking. They can also exist independently
(e.g., castle-gateway runs Caddy — not our software).
Program blocks
Programs define what software exists — identity, source, behavior, builds.
behavior — What role this program plays
behavior: daemon # or: tool, frontend
Explicit declaration of how the program is used:
- daemon — long-running service (python-fastapi stack)
- tool — CLI utility (python-cli stack)
- frontend — web UI (react-vite stack)
source — Where the source lives
source: code/my-tool # your programs, under ~/.castle/code/
source: repo:castle-api # castle's own programs, inside the git repo
The source path is resolved one of three ways (core/src/castle_core/config.py):
source: value |
Resolves to | Used for |
|---|---|---|
code/my-tool (relative) |
$CASTLE_HOME/code/my-tool |
Your own programs |
repo:castle-api |
<repo>/castle-api (via the top-level repo: field) |
Castle's built-in programs |
/abs/path |
as-is | Anything explicitly absolute |
Relative sources resolve against the castle home ($CASTLE_HOME, default
~/.castle — see Infrastructure paths). Most programs
you create live under $CASTLE_HOME/code/ and are recorded as
source: code/<name>. Castle's own programs (CLI, core, castle-api, app) live in
the git repo and use the repo: prefix. When castle deploy writes castle.yaml
back out, it rewrites absolute paths into these relative forms.
stack — Development toolchain
stack: python-fastapi # or: python-cli, react-vite
Stacks define how programs get built, checked, and installed.
system_dependencies — Required system packages
system_dependencies: [pandoc, poppler-utils]
System packages that must be installed for the program to work. Displayed
in castle tool list and the dashboard.
version — Program version
version: "1.0.0"
Optional version metadata.
build — How to build it
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
Programs with build outputs are typically frontends.
Service blocks
Services define how long-running daemons are deployed.
run — How to start it (required)
Discriminated union on runner:
| Runner | Sync | Deploy | Key fields |
|---|---|---|---|
python |
uv sync |
which(program) → installed binary |
program, args |
command |
(none) | which(argv[0]) → resolved path |
argv |
container |
(none) | docker/podman run |
image, command, ports, volumes |
node |
package_manager install |
package_manager run script |
script, package_manager |
remote |
(none) | (none — no local process) | base_url, health_url |
run:
runner: python
program: my-service # name in [project.scripts]
expose — What it exposes
expose:
http:
internal:
port: 9001 # Required for HTTP services
health_path: /health # Used by health polling
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=2).
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]
exec_reload: "caddy reload ..."
defaults — Default environment
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
API_KEY: ${secret:MY_API_KEY}
Castle resolves ${secret:NAME} by reading ~/.castle/secrets/NAME.
Never store secrets in castle.yaml or project directories.
Job blocks
Jobs define how scheduled tasks run. Same blocks as services plus
schedule and timezone.
schedule — Cron expression (required)
schedule: "*/5 * * * *"
timezone: America/Los_Angeles # default
Castle generates a systemd .timer file alongside the .service unit.
Other blocks
Jobs also support run (required), manage, and defaults — same
semantics as services.
How programs get into ~/.castle/code/
Every program's source lives in one place — ~/.castle/code/<name>/. It can
arrive there a few ways:
- Scaffold a new one with
castle create— writes the project into~/.castle/code/<name>/and registers it incastle.yamlwithsource: code/<name>. - Clone an existing project —
git clone <url> ~/.castle/code/<name>, then add aprograms:entry pointing atsource: code/<name>. - Drop files in directly — a
code/<name>/directory is just a working tree; it doesn't have to be under version control to be registered and run.
~/.castle/ and ~/.castle/code/ are not themselves git repos, and there
are no submodules. Each program directory manages its own version control (or
none) independently — some are standalone git clones, others are just loose
files.
Castle's own programs (CLI, core, castle-api, app) are the exception: they live
inside the castle git repo and are referenced with source: repo:<name>.
Registering a new program
Via castle create (recommended)
# Service — scaffolds into ~/.castle/code/, assigns port, registers in castle.yaml
castle create my-service --stack python-fastapi --description "Does something"
# Tool — scaffolds into ~/.castle/code/
castle create my-tool --stack python-cli --description "Does something"
Manually
Clone or create the project under ~/.castle/code/, then add entries to the
appropriate sections of castle.yaml:
# Tool — only needs a program entry
programs:
my-tool:
description: Does something useful
source: code/my-tool
stack: python-cli
behavior: tool
# Service — needs both program and service entries
programs:
my-service:
description: Does something useful
source: code/my-service
stack: python-fastapi
behavior: daemon
services:
my-service:
component: my-service
run:
runner: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy:
caddy: { path_prefix: /my-service }
manage:
systemd: {}
Lifecycle
Service lifecycle
castle create my-service --stack python-fastapi # 1. Scaffold + register
cd ~/.castle/code/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 --stack python-cli # 1. Scaffold + register
cd ~/.castle/code/my-tool && uv sync # 2. Install deps
# ... implement ...
castle test my-tool # 3. Run tests
uv tool install --editable ~/.castle/code/my-tool/ # 4. Install to PATH
Job lifecycle
Jobs are defined in the jobs: section with a run spec and schedule:
jobs:
my-job:
description: Runs nightly
run:
runner: command
argv: ["my-job"]
schedule: "0 2 * * *"
manage:
systemd: {}
castle service enable my-job generates both a .service (Type=oneshot)
and a .timer file.
Infrastructure paths
Castle uses two independent roots, each overridable by an environment
variable (both expand ~ and resolve relative paths):
CASTLE_HOME— config, code, artifacts, and secrets. Default~/.castle.CASTLE_DATA_DIR— program/service data I/O (potentially large; lives on a dedicated volume). Default/data/castle. Decoupled fromCASTLE_HOMEon purpose so bulk data doesn't sit in the home directory.
| What | Where |
|---|---|
| Castle home | $CASTLE_HOME (default ~/.castle) |
| Registry | $CASTLE_HOME/castle.yaml |
| Program source (yours) | $CASTLE_HOME/code/<name>/ |
| Program source (castle's) | <repo>/<name> (via source: repo:<name>) |
| Secrets | $CASTLE_HOME/secrets/<NAME> |
| Generated Caddyfile | $CASTLE_HOME/artifacts/specs/Caddyfile |
| Built frontends | $CASTLE_HOME/artifacts/content/<name>/ |
| Service data | $CASTLE_DATA_DIR/<name>/ (default /data/castle/<name>/) |
| Systemd units | ~/.config/systemd/user/castle-*.service |
| Systemd timers | ~/.config/systemd/user/castle-*.timer |
Defined in core/src/castle_core/config.py: CASTLE_HOME (with derived
CODE_DIR, SECRETS_DIR, SPECS_DIR, CONTENT_DIR) and the independent
DATA_DIR (CASTLE_DATA_DIR). castle deploy passes each service its data
path via the generated <PREFIX>_DATA_DIR env var. Systemd unit/timer paths are
fixed by systemd's user-unit convention.
Manifest models
The Pydantic models live in core/src/castle_core/manifest.py. Key classes:
ProgramSpec— software catalog entry (source, behavior, stack, build, system_dependencies)ServiceSpec— long-running daemon (run, expose, proxy, manage, defaults)JobSpec— scheduled task (run, schedule, manage, defaults)RunSpec— discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)ExposeSpec,ProxySpec,ManageSpec,BuildSpecCaddySpec,SystemdSpec,HttpExposeSpec,HttpInternal
Config loading: core/src/castle_core/config.py — load_config() parses
castle.yaml into CastleConfig with typed programs, services, and
jobs dicts.
Infrastructure generators: core/src/castle_core/generators/ — systemd unit/timer
generation (systemd.py) and Caddyfile generation (caddyfile.py).