+ Use ${"{port}"},{" "}
+ ${"{data_dir}"},{" "}
+ ${"{name}"} for castle's computed values,
+ and ${"{secret:NAME}"} for secrets.
+
{Object.entries(env).map(([key, val]) => (
diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py
index 6c52244..67efa1e 100644
--- a/cli/src/castle_cli/commands/create.py
+++ b/cli/src/castle_cli/commands/create.py
@@ -8,6 +8,7 @@ import subprocess
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import (
CaddySpec,
+ DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -92,6 +93,7 @@ def run_create(args: argparse.Namespace) -> int:
behavior=behavior,
)
if behavior == "daemon":
+ prefix = name.replace("-", "_").upper()
config.services[name] = ServiceSpec(
id=name,
program=name,
@@ -104,6 +106,11 @@ def run_create(args: argparse.Namespace) -> int:
),
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()),
+ # python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's
+ # computed port/data dir to those vars (explicit, no hidden injection).
+ defaults=DefaultsSpec(
+ env={f"{prefix}_PORT": "${port}", f"{prefix}_DATA_DIR": "${data_dir}"}
+ ),
)
save_config(config)
diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py
index db310f8..b13316b 100644
--- a/cli/src/castle_cli/commands/deploy_create.py
+++ b/cli/src/castle_cli/commands/deploy_create.py
@@ -12,6 +12,7 @@ import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
+ DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -25,6 +26,17 @@ from castle_cli.manifest import (
)
+def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
+ """Parse repeated --env KEY=VALUE into a DefaultsSpec, or None."""
+ if not env_args:
+ return None
+ env: dict[str, str] = {}
+ for item in env_args:
+ key, _, value = item.partition("=")
+ env[key.strip()] = value
+ return DefaultsSpec(env=env)
+
+
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command":
return RunCommand(runner="command", argv=target.split() or [name])
@@ -54,7 +66,7 @@ def run_service_create(args: argparse.Namespace) -> int:
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
- internal=HttpInternal(port=args.port, port_env=args.port_env),
+ internal=HttpInternal(port=args.port),
health_path=args.health,
)
)
@@ -76,13 +88,14 @@ def run_service_create(args: argparse.Namespace) -> int:
expose=expose,
proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()),
+ defaults=_defaults(args.env),
)
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
if expose:
- print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else ""))
+ print(f" port: {args.port}")
if proxy and proxy.caddy:
if proxy.caddy.path_prefix:
print(f" proxy: {proxy.caddy.path_prefix}")
@@ -109,6 +122,7 @@ def run_job_create(args: argparse.Namespace) -> int:
run=run,
schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()),
+ defaults=_defaults(args.env),
)
save_config(config)
diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py
index 0de0903..83b56d3 100644
--- a/cli/src/castle_cli/main.py
+++ b/cli/src/castle_cli/main.py
@@ -77,12 +77,17 @@ def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p.add_argument("--description", default="", help="Description")
p.add_argument("--run", help="Console script / command to run (default: --program or name)")
p.add_argument("--runner", choices=["python", "command"], default="python")
+ p.add_argument(
+ "--env",
+ action="append",
+ metavar="KEY=VALUE",
+ help="Env var for the program (repeatable). Use ${port}/${data_dir}/${name} placeholders.",
+ )
if kind == "service":
p.add_argument("--port", type=int, help="HTTP port")
p.add_argument("--health", default="/health", help="Health path (default: /health)")
p.add_argument("--path", help="Gateway proxy prefix (default: /)")
p.add_argument("--host", help="Route by hostname instead of a path prefix")
- p.add_argument("--port-env", help="Env var the program reads for its port")
p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route")
else:
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)")
diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py
index 6d0621f..9bb8b20 100644
--- a/core/src/castle_core/config.py
+++ b/core/src/castle_core/config.py
@@ -141,16 +141,27 @@ class CastleConfig:
}
-def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
- """Resolve ${secret:NAME} references in env values."""
+def resolve_env_vars(
+ env: dict[str, str], context: dict[str, str] | None = None
+) -> dict[str, str]:
+ """Resolve placeholders in env values.
+
+ - ``${secret:NAME}`` reads `~/.castle/secrets/NAME`.
+ - ``${port}`` / ``${data_dir}`` / ``${name}`` (and anything else in
+ ``context``) expand to castle's computed values, so a service maps them to
+ whatever env var its program reads (e.g. ``MY_PORT: ${port}``) without
+ hardcoding or castle silently injecting a guessed var name.
+ """
+ context = context or {}
resolved = {}
for key, value in env.items():
def replace_var(match: re.Match[str]) -> str:
ref = match.group(1)
if ref.startswith("secret:"):
- secret_name = ref[7:]
- return _read_secret(secret_name)
+ return _read_secret(ref[7:])
+ if ref in context:
+ return context[ref]
return match.group(0)
resolved[key] = re.sub(r"\$\{([^}]+)\}", replace_var, value)
diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py
index d93ce99..28e5fd0 100644
--- a/core/src/castle_core/deploy.py
+++ b/core/src/castle_core/deploy.py
@@ -157,9 +157,12 @@ def _reload_gateway(messages: list[str]) -> None:
# ---------------------------------------------------------------------------
-def _env_prefix(name: str) -> str:
- """Derive env var prefix from name: central-context → CENTRAL_CONTEXT."""
- return name.replace("-", "_").upper()
+def _env_context(name: str, config_key: str, port: int | None) -> dict[str, str]:
+ """Placeholder values for defaults.env: ${name}/${data_dir}/${port}."""
+ ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)}
+ if port is not None:
+ ctx["port"] = str(port)
+ return ctx
def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> str | None:
@@ -176,41 +179,26 @@ def _build_deployed_service(
) -> Deployment:
"""Build a Deployment from a ServiceSpec."""
run = svc.run
- # Env prefix and data dir are keyed by the program (component) the service
- # runs, not the service name — that's the prefix the program's settings read
- # (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR).
- # Falls back to the service name when no component is referenced.
+ # The data-dir placeholder is keyed by the program the service runs, not the
+ # service name (e.g. job `protonmail-sync` runs program `protonmail` →
+ # /data/castle/protonmail). Falls back to the service name.
config_key = svc.program or name
- prefix = _env_prefix(config_key)
- env: dict[str, str] = {}
- # Data dir convention (for managed services). Skipped for container runners:
- # they use volume mounts, not a data-dir env var, and the injected name can
- # collide with an image's own env namespace (e.g. neo4j claims NEO4J_*).
managed = run.runner != "remote"
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
- if managed and run.runner != "container":
- env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / config_key)
- # Port convention (if exposed)
port = None
health_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
- env[f"{prefix}_PORT"] = str(port)
- # If the program reads a non-convention port var, set that too so castle
- # actually controls the bind (e.g. adopted lakehoused → LAKEHOUSED_DAEMON_PORT).
- if svc.expose.http.internal.port_env:
- env[svc.expose.http.internal.port_env] = str(port)
health_path = svc.expose.http.health_path
- # Merge defaults.env (overrides conventions)
- if svc.defaults and svc.defaults.env:
- env.update(svc.defaults.env)
-
- # Resolve secrets
- env = resolve_env_vars(env)
+ # Env is exactly what's declared in defaults.env — no hidden convention
+ # injection. ${port}/${data_dir}/${name} let the program's own env var names
+ # map to castle's computed values without hardcoding them.
+ env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
+ env = resolve_env_vars(env, _env_context(name, config_key, port))
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, svc.program, messages)
@@ -260,18 +248,11 @@ def _build_deployed_job(
) -> Deployment:
"""Build a Deployment from a JobSpec."""
run = job.run
- # Keyed by the program (component) the job runs, not the job name — see
- # _build_deployed_service for rationale. Falls back to the job name.
+ # ${data_dir} is keyed by the program the job runs, not the job name — see
+ # _build_deployed_service. Falls back to the job name.
config_key = job.program or name
- prefix = _env_prefix(config_key)
- env: dict[str, str] = {}
-
- env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / config_key)
-
- if job.defaults and job.defaults.env:
- env.update(job.defaults.env)
-
- env = resolve_env_vars(env)
+ env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
+ env = resolve_env_vars(env, _env_context(name, config_key, None))
_ensure_python_tool(config, job.program, messages)
run_cmd = _build_run_cmd(name, run, env, messages)
diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py
index a3a1920..cbc5ca5 100644
--- a/core/src/castle_core/manifest.py
+++ b/core/src/castle_core/manifest.py
@@ -111,12 +111,6 @@ class HttpInternal(BaseModel):
host: str = "127.0.0.1"
port: int = Field(ge=1, le=65535)
unix_socket: str | None = None
- # Env var the program actually reads for its bind port. Castle's convention
- # injects _PORT, but an adopted program may read a different name
- # (e.g. lakehoused reads LAKEHOUSED_DAEMON_PORT). When set, deploy also sets
- # this var to `port`, so castle genuinely drives the bind instead of relying
- # on the program's default happening to match.
- port_env: str | None = None
class HttpPublic(BaseModel):
diff --git a/docs/design.md b/docs/design.md
index f46620e..e8021db 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -195,9 +195,9 @@ Services and jobs can reference a program via `program:` for description
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
-Convention-based env vars (`_PORT`, `_DATA_DIR`) are
-generated automatically during deploy. Only non-convention values need
-`defaults.env`.
+A service's env is exactly its `defaults.env` — castle injects nothing
+implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
+placeholders, which deploy resolves into the registry's flat `env`.
**`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle deploy`) — Node config:
@@ -222,8 +222,8 @@ deployed:
```
The node config says what's deployed *here* and with what concrete
-values. `castle deploy` reads the spec from the repo, generates
-convention-based env vars, resolves secrets, resolves binary paths,
+values. `castle deploy` reads the spec from the repo, resolves the
+`defaults.env` placeholders and secrets, resolves binary paths,
and writes the registry. Systemd units and Caddyfile are then generated
from the registry — never from the spec directly.
@@ -521,9 +521,9 @@ What exists today:
`$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and
Caddyfile generated from registry with fully resolved paths. No repo references
in runtime artifacts.
-- **Convention-based env generation** — `castle deploy` auto-generates
- `_DATA_DIR=$CASTLE_DATA_DIR/` and `_PORT` from
- the manifest. Only non-convention values need `defaults.env`.
+- **Explicit env with placeholders** — a deployment's env is exactly its
+ `defaults.env`; `castle deploy` resolves `${port}`/`${data_dir}`/`${name}`/
+ `${secret:…}` into concrete values. No hidden convention injection.
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry
- **API** — `castle-api` on port 9020, reads from registry (optional
castle.yaml fallback for non-deployed programs)
diff --git a/docs/findings-lakehouse.md b/docs/findings-lakehouse.md
index 08f7dc9..8ae53c7 100644
--- a/docs/findings-lakehouse.md
+++ b/docs/findings-lakehouse.md
@@ -40,10 +40,13 @@ the daemon's built-in default and we declared the same value. Castle's
convention assumes a castle-aware program; an adopted one doesn't read
`_PORT`.
-**Fix:** let a service declare which env var carries the port, e.g.
-`expose.http.port_env: LAKEHOUSED_DAEMON_PORT`, so castle sets *that* to the
-configured port and genuinely drives the bind. (`defaults.env` is the manual
-workaround.)
+**Fixed** (superseding the interim `port_env` field): auto-injection of the
+convention vars was dropped entirely. A service's env is now exactly its
+`defaults.env`, with `${port}`/`${data_dir}`/`${name}` placeholders for castle's
+computed values. lakehouse maps just what it reads —
+`LAKEHOUSED_DAEMON_PORT: ${port}` — and keeps its own data root, with no dead
+vars. Castle-native services map their `_PORT`/`_DATA_DIR` the same way
+(scaffolded by `castle program create`).
### #3 — `castle deploy` regenerates the Caddyfile but never reloads Caddy (High, bug)
diff --git a/docs/registry.md b/docs/registry.md
index 1efcb7f..92c0de3 100644
--- a/docs/registry.md
+++ b/docs/registry.md
@@ -255,17 +255,36 @@ manage:
exec_reload: "caddy reload ..."
```
-### `defaults` — Default environment
+### `defaults` — Environment
+
+`defaults.env` is the **single, explicit source** of the env a service/job runs
+with — what you write here is exactly what lands in the systemd unit. Castle
+does **not** inject hidden convention vars; whatever env var your program reads
+for its port, data dir, etc., you map here.
```yaml
+expose: { http: { internal: { port: 9001 }, health_path: /health } }
defaults:
env:
+ MY_SERVICE_PORT: ${port} # the program's own port var ← expose.port
+ MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/
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.
+Values may contain placeholders that castle resolves at deploy:
+
+| Placeholder | Expands to |
+|-------------|------------|
+| `${port}` | the service's `expose.http.internal.port` (so it can't drift) |
+| `${data_dir}` | `$CASTLE_DATA_DIR/` (the dedicated data volume) |
+| `${name}` | the deployment name |
+| `${secret:NAME}` | the contents of `~/.castle/secrets/NAME` |
+
+Hardcode the values instead if you prefer; the placeholders just save you from
+repeating castle's computed paths/ports. `castle program create` scaffolds the
+`${port}`/`${data_dir}` lines for new services. Never store secrets in
+castle.yaml — use `${secret:…}`.
## Job blocks
@@ -431,9 +450,10 @@ variable (both expand `~` and resolve relative paths):
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 `_DATA_DIR` env var. Systemd unit/timer paths are
-fixed by systemd's user-unit convention.
+`DATA_DIR` (`CASTLE_DATA_DIR`). A service reaches its data path by mapping
+`${data_dir}` (= `$CASTLE_DATA_DIR/`) to the env var its program reads, in
+`defaults.env`. Systemd unit/timer paths are fixed by systemd's user-unit
+convention.
## Manifest models
diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md
index 3651c8d..3d7afec 100644
--- a/docs/stacks/python-fastapi.md
+++ b/docs/stacks/python-fastapi.md
@@ -129,16 +129,21 @@ services:
systemd: {}
```
-Convention-based env vars (`MY_SERVICE_DATA_DIR`, `MY_SERVICE_PORT`) are
-generated automatically by `castle deploy`. Only non-convention values
-need `defaults.env`:
+The env a service runs with is exactly what's in `defaults.env` — castle injects
+nothing implicitly. Map the vars your settings read (above, `env_prefix:
+"MY_SERVICE_"` → `MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to castle's computed
+values with the `${port}`/`${data_dir}` placeholders:
```yaml
defaults:
env:
+ MY_SERVICE_PORT: ${port} # = expose.http.internal.port
+ MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
```
+`castle program create` scaffolds the `${port}`/`${data_dir}` lines for you.
+
## Application entry point
```python
@@ -305,8 +310,9 @@ $CASTLE_DATA_DIR/my-service/ # default /data/castle/my-service/
└── item-name.meta.json
```
-The service receives this path via the generated `_DATA_DIR` env var —
-it never hardcodes it. Use the `data_dir` setting from your config.
+The service receives this path via its `_DATA_DIR` env var, which
+`defaults.env` maps from `${data_dir}` — it never hardcodes it. Use the
+`data_dir` setting from your config.
```python
# storage.py