| name | deploy |
| description | MUST be invoked before any work involving: `charly bundle add`/`charly bundle del` commands, quadlet generation, volume backing, tunnels (Tailscale/Cloudflare), `add_candy:` overlay, or per-machine deploy overlays.
|
Deploy - Deployment Configuration
Schema overview
- A deploy is name-first; the target is the SUBSTRATE KIND at the edge — the deploy's first child key is exactly one of
pod: / vm: / k8s: / local: / android: (or group: for a targetless group of members) and selects local | vm | pod | k8s | android. There is NO authored target: field. Inside that substrate node, image: names the box/OCI image a pod:/k8s:/android: runs and from: inherits a same-kind vm/k8s/local/android template by name.
- The substrate node at the edge —
vm: {from: <entity>} for a VM target, pod: {image: <name>} for a pod target, k8s: {from: <name>} for a cluster target, local: {from: <template>} for a local-deploy. Deploy-into nesting is tree position (a resource node placed under another resource), not a nested: field; sibling members (a resource node placed under the deploy) replace the old peer: field.
host: is a FIELD on local:, never a deploy KIND (MUST). A host/remote deploy MUST be authored as local: {from: <template>, host: <user@machine>} — the host: scalar on a local: (or pod:) deploy: host: local / absent = this machine, host: <user@machine> = SSH. There is NO standalone host: venue KIND; authoring a host: node is a hard load error. See /charly-local:local-deploy.
group: is EXCLUSIVELY a targetless deploy group; a Calamares package group is package-group: (MUST). A group: node carries only resource members (pod/vm/etc.) with no own workload — the targetless deploy group. A Calamares installer package group (the #Group schema — name/package/subgroup/distro/…) MUST use the separate package-group: kind, never group:. See /charly-image:layer.
- Disposability is a deploy property —
BundleNode.Disposable (the disposable: scalar under the substrate node) is the sole source of truth.
- Resource arbitration is a deploy property — a
<name>-preemptible child node (holder; occupies exclusive host-resource token(s), may be gracefully stopped + restored) a <name>-requires_exclusive child node (claimant; needs SOLE use), and a <name>-requires_shared child node (claimant; REFCOUNTED shared use — many concurrent claimants; a GPU-token claim flips the whole IOMMU group vfio→nvidia+CDI at deploy, back at teardown when the LAST claimant releases) drive the arbiter (charly preempt). A fourth axis ORTHOGONAL to disposable/ephemeral/lifecycle. See "Preemptible resource arbitration" below + /charly-internals:disposable.
- Disposable R10 test beds are just
disposable: true deploys (run via charly check run <bed>) — there is NO separate bed kind. The main repo's beds (check-pod, check-k3s-vm, check-sway-browser-vnc-pod, …) AND every box/<distro> submodule's beds (the arch / cachyos / debian / ubuntu / fedora bootstrap-VM + pacstrap/debootstrap beds, in each submodule's charly.yml) are disposable deploys. The cachyos submodule's charly-cachyos operator workstation profile is a non-disposable deploy (a profile, not a test bed). Operator deployments otherwise live in the per-host ~/.config/charly/charly.yml. See /charly-check:check "disposable check deploys".
Overview
charly bundle is the parent verb for applying and tearing down deployments, plus managing charly.yml overrides. The command family has two distinct surfaces:
- Execution verbs —
charly bundle add <name> / charly bundle del <name>. Apply or reverse a deployment. The target is dispatched by which substrate node the deploy carries:
local: {from: <template>} → the external deploy:local plugin (candy/plugin-deploy-local) applies to the local filesystem over the executor reverse channel (or, placed under another resource node via tree position, via NestedExecutor into the enclosing deployment). See /charly-local:local-deploy.
vm: {from: <entity>} → the external deploy:vm plugin (candy/plugin-deploy-vm) applies INSIDE a running VM over SSH via the reverse channel; the host-side vmSubstrateLifecycle hook boots the venue. See "VM target" section below and /charly-internals:vm-deploy-target.
pod: {image: <image>} → PodDeployTarget: overlay Containerfile + quadlet/podman.
k8s: {from: <name>} → Kustomize base/overlays tree. See /charly-kubernetes:kubernetes.
- Config-file management —
charly bundle show/export/import/reset/path/status. Read and mutate ~/.config/charly/charly.yml itself.
Targets, one schema
The same BundleNode shape feeds every target (pod, local, vm, k8s, android) — authors describe what the workload needs (ports, volumes, env, security, tests); the generator per target decides how K8s / quadlet / local apply / VM over SSH / Android apk-install realizes it.
A deploy's install timeline can include a run: plugin: <verb> step. When the verb is served by an EXTERNAL (out-of-process) plugin, that step EXECUTES at deploy on a local:/vm: target — the plugin runs its effect on the live venue over the executor reverse channel (Invoke(OpExecute)) and returns teardown ReverseOps the target records to the ledger and replays at charly bundle del. A builtin ProvisionActor verb keeps its in-proc shell path; the two are placement-agnostic (same authored step, the runtime picks the path). Detail → /charly-internals:install-plan (the ExternalPluginStep IR kind) + /charly-internals:plugin.
An android: deploy installs a deploy's add_candy: candies' apk: packages
onto an android device entity (an in-pod emulator or a remote/physical adb
endpoint) via the EXTERNAL deploy:android substrate (F1 — served out-of-process
by candy/plugin-adb; the host preresolves the device endpoint + apk specs, the
plugin installs) — the Android analogue of a k8s: deploy emitting workloads onto
a cluster. The substrate node is android: {from: <device>}; apps
ride in on the add_candy: child node (no apk-list field). A pod → android tree
(the device placed under its emulator-pod node) mirrors vm → k8s; a pod's android
children deploy AFTER charly start (use --node-only on the pod's deploy-add, then
dotted-path charly bundle add pod.device). See /charly-check:android. K8s-specific choices (storage class, ingress class, cert issuer, secret backend) live in a cluster profile file (~/.config/charly/clusters/<name>.yaml or in-repo clusters/<name>.yaml), not in the deployment. This means one deployment spec targets dev/staging/prod clusters with zero schema changes — only the cluster profile differs.
charly start / charly stop remain as ergonomic wrappers: charly start <image> is equivalent to charly bundle add <image> <image> with the container target; charly stop <name> is charly bundle del <name>. New scripts should prefer the explicit charly bundle add/charly bundle del forms, especially when using --add-candy overlays or the host target.
Quick Reference
| Action | Command | Description |
|---|
| Apply container deploy | charly bundle add <name> <ref> | Compile candies + build overlay if add_candy: present + run via quadlet |
| Apply local deploy | charly bundle add <name> <ref> (entry with a local: substrate node) | Apply layers directly to local filesystem (see /charly-local:local-deploy) |
| Tear down deploy | charly bundle del <name> | Stop container + reverse ReverseOps (host) + ledger cleanup |
| Dry-run | charly bundle add <name> <ref> --dry-run [--format=json] | Print the InstallPlan without executing |
| Layer overlay | charly bundle add <name> <ref> --add-candy <ref> | Extra layer(s) applied on top; repeatable |
| Configure deployment | charly config <image> | Generate .container file + save charly.yml |
| Configure instance | charly config <image> -i <instance> | Generate instance-specific quadlet + deploy entry |
| Configure volume backing | charly config <image> --bind name | Set volume as host bind mount |
| Provision data | charly config <image> --seed | Auto-provision data candies into bind mounts (default) |
| Deploy status | charly bundle status | Audit charly.yml vs quadlet sync |
| Show overrides | charly bundle show [image] | Display charly.yml contents |
| Show instance overrides | charly bundle show <image> -i <instance> | Display instance-specific overrides |
| Import config | charly bundle import <files> | Merge files into charly.yml |
| Reset config | charly bundle reset [image] | Remove charly.yml overrides |
| Reset instance config | charly bundle reset <image> -i <instance> | Remove instance overrides |
| Push to registry | charly box build --push | Multi-platform push |
For service lifecycle commands (start/stop/status/logs/update/remove), see /charly-core:service. For VM lifecycle (build/create/start/stop/ssh), see /charly-vm:vm; for in-VM layer deploys via charly bundle add vm:<name>, see the "VM target" section below and /charly-internals:vm-deploy-target. For encrypted storage, see /charly-automation:enc. For host-target semantics, see /charly-local:local-deploy. For Kubernetes targets, see /charly-kubernetes:kubernetes. For the Go IR that drives all four targets, see /charly-internals:install-plan.
Command Family: add / del
charly bundle add <name> [<ref>]
Applies a deployment. The substrate node selects the target:
local: {from: <template>} — apply layers to the local filesystem via the external deploy:local plugin (candy/plugin-deploy-local) over the executor reverse channel. With host: local (default) the apply runs through a ShellExecutor directly; with host: <user@machine> it runs over an SSHExecutor (picked by rootExecutorForDeployNode). See /charly-local:local-deploy.
vm: {from: <entity>} — apply layers inside a running vm entity via SSH (the external deploy:vm plugin, candy/plugin-deploy-vm, over the reverse channel). <vm-name> must match a vm entity; the VM must already be created (charly vm create <vm-name>), or the vmSubstrateLifecycle hook auto-boots it. See "VM target" section below.
k8s: {from: <name>} — emit a Kustomize base/overlays tree. See /charly-kubernetes:kubernetes.
pod: {image: <image>} (pod, the default target) — container deployment. Multiple pod deploys coexist (my-dev, postgres-staging, etc.); each gets its own quadlet, container name, and charly.yml entry.
<ref> accepts four forms, auto-detected:
| Form | Example | Resolution |
|---|
| Local image name | fedora-coder | Looked up in current project's charly.yml |
| Local layer name | pre-commit | Looked up in current project's candy/ directory |
| Local YAML path | ./custom.yml, /abs/path/charly.yml | File's top-level keys classify image vs layer |
| Remote repo | github.com/owner/repo[/box/<n>|/candy/<n>][@ref] | Fetched via existing --repo cache |
Disambiguation: a ref containing /candy/ resolves to a candy; /box/ to a box. For local names, box/ is checked before candy/; same-named entries in both are a hard error. The legacy @host/org/repo:version form (used by require: and candy: in charly.yml) is also accepted.
When <ref> is omitted, the ref falls back to the deploy's image: field (or the deploy key itself if no explicit image is declared).
charly bundle add flags
Universal:
--tag <calver> — override charly.yml tag
--dry-run — print the InstallPlan without executing
--format table|json — with --dry-run
--pull — force re-fetch of remote refs / image pull
--verify — run layer check: post-deploy
--add-candy <ref> — repeatable; extra layer(s) applied on top (all 4 ref forms)
Local-target-specific (silently ignored on pod deploys):
--with-services — opt-in for systemd unit installation (packaged-unit enable + drop-ins)
--allow-repo-changes — opt-in for repo config mutations (rpmfusion, copr, external .repo files)
--allow-root-tasks — opt-in for arbitrary cmd: user: root task bodies
--skip-incompatible — skip layers lacking a host-matching format section instead of failing
--builder-image <ref> — override the compile-builder image
--yes / -y — all three gates plus skip sudo preflight
charly bundle del <name>
Reverses a deployment. Gated host-side reversal respects --keep-repo-changes and --keep-services. Container teardown: podman stop + rm + overlay image removal (unless --keep-image) + ledger cleanup. VM teardown: SSH-executed ReverseOps in the guest, preserving the VM itself (use charly vm destroy separately). See /charly-local:local-deploy for the full 15-kind ReverseOp table.
VM target: charly bundle add vm:<vm-name> <ref>
Applies layer recipes inside a running VM over SSH. Same InstallPlan IR as host and container targets; the difference is that bash bodies run via ssh guest 'sudo bash -s' through an SSHExecutor (see /charly-internals:vm-deploy-target).
Prerequisite: the VM must exist before charly bundle add vm:... runs. charly bundle add vm:<name> does NOT auto-provision; a missing VM produces a clean error pointing at charly vm create:
charly vm create arch
charly bundle add vm:arch ripgrep
charly bundle add vm:arch fedora-coder \
--add-candy team-extras \
--add-candy github.com/team/configs/candy/sshkeys
charly bundle del vm:arch
VmDeployState schema in charly.yml
When charly bundle add vm:<name> completes, a vm_state child node lands in the charly.yml deploy:
arch:
vm:
from: arch
arch-vm_source:
vm_source:
kind: cloud_image
url: https://fastly.mirror.pkgbuild.com/...
base_user: arch
arch-add_candy:
add_candy:
- ripgrep
- github.com/team/configs/candy/sshkeys
arch-install_opts:
install_opts:
verify: true
arch-vm_state:
vm_state:
instance_id: 7b3a8f42-...
nvram_path: ""
last_build: 2026-04-22T10:14:27Z
last_deploy: 2026-04-22T10:18:55Z
applied_layers: [ripgrep, sshkeys]
base_image_sha256: a8c9e0f1...
vm_state is persisted so re-applies pick up instance_id (cloud-init uses it as a stable identifier) and so charly bundle del vm:<name> knows which NVRAM to use. SSH access uses the managed charly-<vmname> ssh-config alias written by charly vm create.
Marking a deploy VM-targeted: the vm: substrate node
A deploy is VM-targeted when its first child key is the vm: substrate node carrying from: <entity> — there is no target: field. CLI dispatch additionally accepts a vm:<vm-name> prefix on the deploy-name argument (charly bundle add vm:arch), which ResolveTarget reads to route through externalDeployTarget (the adapter for the external vm substrate). The prefix is an addressing convenience; the vm: substrate node is the source of truth. Naming a vm: deploy whose vm entity isn't declared in charly.yml errors at charly bundle add time; a declared-but-unbooted VM is auto-booted by the lifecycle hook's PrepareVenue.
add_candy: overlay semantics for VM targets
When charly bundle add vm:<name> <ref> runs with --add-candy, the extra layers are applied inside the guest alongside the primary ref. The compiler merges <ref> + add_candy: into a single topo-sorted InstallPlan; the external deploy:vm plugin's kit.WalkPlans walks it inside the guest over SSH. The host-side ledger (keyed by computeDeployID) records both the base and overlay layers' teardown ops so charly bundle del vm:<name> reverses the full set.
This is the same merge semantics as a local: deploy — just with SSH-wrapped execution. See /charly-internals:install-plan for the compiler and /charly-internals:vm-deploy-target for the execution model.
VM-relevant install_opts: fields
install_opts: in the charly.yml entry mirrors the CLI flags on charly bundle add. For VM targets, the relevant ones are:
| Field | Effect |
|---|
with_service | Enable systemd units declared in layers' service: lists |
allow_repo_changes | Permit repo-config mutations (rpmfusion, copr) in the guest |
allow_root_tasks | Permit arbitrary cmd: user: root tasks in the guest |
verify | Run layer check: over SSH post-deploy |
skip_incompatible | Skip layers lacking a guest-matching format section |
builder_image + yes also apply. Host-target-only gates that don't apply to VM target: none — the gate semantics are identical.
Prerequisite chain
charly.yml declares a vm entity (`<name>: {vm: …}`)
→ charly vm build <name> (cloud_image: fetch+resize+seed ISO; bootc: install to-disk)
→ charly vm create <name> (libvirt domain + SMBIOS ssh key + passt portForward)
→ charly vm start <name> (boot)
→ charly bundle add vm:<name> <ref> (SSH → cloud-init wait → charly install → layer apply)
→ charly bundle del vm:<name> (SSH → ReverseOps; VM stays up)
→ charly vm destroy <name> (remove libvirt domain; --disk to also delete qcow2)
See /charly-vm:vms-catalog for vm-entity authoring, /charly-vm:vm for the lifecycle commands, /charly-internals:vm-deploy-target for the Emit flow.
add_candy: overlay mechanism
Both container and host targets accept extra candies at deploy time via --add-candy <ref> (repeatable) or the deploy's add_candy: child node. Semantics:
- Container target: synthesizes an overlay Containerfile (
FROM <base-image> + the extra candies' build steps) and builds a deterministic overlay image tagged <deploy-name>-overlay:<short-hash>. The deploy runs the overlay, not the base image. Re-running with different overlays rebuilds. charly bundle del <name> removes the overlay unless --keep-image.
- Host target: the compiler merges the box's candies with
add_candy:, topo-sorts the union, and compiles one InstallPlan covering the combined set. The ledger records which candies (base + overlay) were applied so teardown reverses everything.
Ref forms for --add-candy are identical to the primary <ref> positional (local name / local path / remote / legacy @ form).
Two supported deploy patterns
Every pod deploy MUST declare its image: field
explicitly (a pod deploy is defined by carrying image:; hard
load-time error if absent — see "Why image: is required" below).
With that contract locked, two distinct patterns are supported:
Pattern A — Multiple instances of the same image
Use the <base>/<instance> deploy-name form when you want N
container instances backed by the same image (one per tenant,
per workspace, per environment, …):
versa:
pod:
image: versa
versa/ecovoyage:
pod:
image: versa
versa-ecovoyage-volume:
volume:
- name: workspace
type: bind
host: /home/user/projects/ecovoyage
versa-ecovoyage-port:
port:
- "32718:2718"
versa/another-tenant:
pod:
image: versa
versa-another-tenant-volume:
volume:
- name: workspace
type: bind
host: /srv/another-tenant/workspace
Container names: charly-versa, charly-versa-ecovoyage,
charly-versa-another-tenant. Equivalent CLI invocations:
charly update versa -i ecovoyage ↔ charly update versa/ecovoyage (the
-i <inst> flag and the / suffix are interchangeable for
addressing instance deploys).
Pattern B — Arbitrary deploy name with image (and optional version pin)
The deploy key is purely a name; it does NOT have to match the
image: value. Use this for version pinning, canary deploys, or
when you want a more descriptive deploy name than the image name
itself:
versa-prod:
pod:
image: versa
versa-pinned-2026.131.2134:
pod:
image: ghcr.io/overthinkos/versa:2026.131.2134
versa-canary:
pod:
image: ghcr.io/overthinkos/versa:next
Container names: charly-versa-prod, charly-versa-pinned-2026.131.2134,
charly-versa-canary. Only the single <key> form addresses these
(charly update versa-prod, NOT charly update versa -i prod — that would
target an instance of versa, which is a different deploy).
Schema rules locked down by these patterns
image: is REQUIRED on every pod deploy. Hard
load-time error if absent, with a remediation hint pointing at
charly migrate (the one-shot migration that injects
the field into legacy entries).
- The
image: value is either:
- a short name (e.g.
versa) — resolved against the candy:
image entries (those carrying base:/from:) in charly.yml to the currently-built tag, OR
- a fully-qualified registry ref (e.g.
ghcr.io/overthinkos/versa:2026.131.2134 or
…@sha256:…) — pinned to that exact image, never re-resolved.
Use this for version pinning and canary deploys.
- The deploy key is purely a name. It may contain
/ to express
the multi-instance pattern (<base>/<instance>) but does NOT have
to match image:. versa-old, versa-canary, and
my-tenant/staging are all valid deploy keys.
- Container name rule:
charly-<key-with-slash-replaced-by-dash>
(e.g. charly-versa, charly-versa-ecovoyage, charly-versa-canary).
charly update <key> and charly update <base> -i <inst> are
equivalent ways to address a <base>/<inst> deploy (Pattern A).
For Pattern B (arbitrary name), only the single <key> form
works.
Auto port mapping — inherited container ports, auto-allocated on 127.0.0.1
A box's published CONTAINER ports are inherited from its candy chain (boxes
declare no port: — see /charly-image:image "Ports"). At charly config,
ResolveDeployPorts maps EACH inherited container port to a host:container
publish entry: a fresh free 127.0.0.1 host port is auto-allocated unless
the deploy explicitly pins it. The result persists as resolved_port: and a
prior allocation is reused for stability across charly update. localizePort +
BindAddress (default 127.0.0.1) bind every published port — auto-allocated
and pinned alike — to loopback only; charly status shows the live mapping and
check probes resolve it via ${HOST_PORT:N}.
A deploy/bed port: entry is a PIN (host:container) for specific container
ports — the rest still auto-allocate (it is no longer a wholesale replacement,
and the legacy port: [auto] sentinel is retired — absence IS auto). Pin only
when you need a fixed, predictable host port:
jupyter:
pod:
image: jupyter
jupyter-port:
port: ["8888:8888"]
Port resolution is keyed by deploy-key, not image short-name. Each deploy
entry's quadlet PublishPort= is sourced from THAT entry's own
resolved_port:/port:, looked up by its deploy key. Multiple deploys backed by
the same image short-name on one host (Pattern A instances, Pattern B
pinned/canary deploys, and disposable check deploys whose image: matches a running
production deploy) each get their own independent ports — a bed's
auto-allocation never collides with a sibling's (the allocator excludes other
deploys' host ports). MergeDeployOntoMetadata and dc.Lookup both take the
deploy key (typically c.Image), never a value derived from the baked
ai.opencharly.box label, so an entry's resolved ports are never clobbered by a
sibling that merely shares the image.
Why image: is required (R10 implication)
The check runner inspects exactly the image the operator declared in
image:, never what the container happens to be. Without an explicit
image: field the runner would resolve the running container's image
ref via containerImageRef(); for volume-pinned deploys (where
data_source: seeds workspace from a specific image tag) the running
container sits at the seed-version, not the current image, so the
runner would read the older OCI label and silently drop any probes
added since. Requiring image: makes the inspected image deterministic.
Examples
Deploy a local image as a container:
charly bundle add my-dev fedora-coder
charly bundle del my-dev
Deploy directly to the host:
charly bundle add host fedora-coder --with-services --assume-yes
charly bundle del host --assume-yes
Deploy from a remote repo:
charly bundle add my-coder github.com/overthinkos/overthink/box/fedora-coder@main
charly bundle add host github.com/team-acme/private-configs/candy/my-team-tools
Add overlay layers:
charly bundle add host fedora-coder \
--add-candy team-extras \
--add-candy github.com/team/configs/candy/sshkeys \
--add-candy ./private.yml \
--with-services
Dry-run to preview the plan:
charly bundle add host fedora-coder --dry-run --format=json
charly start/charly stop equivalence:
charly start fedora-coder
charly stop fedora-coder
Quadlet Generation
User-level systemd services via podman quadlet. Generated by charly config.
Generated File
Path: ~/.config/containers/systemd/charly-<image>.container (or charly-<image>-<instance>.container with -i).
Contents include:
[Container] section: image reference, container name, port mappings, volumes, environment
[Service] section: restart policy, lifecycle hooks
[Install] section: WantedBy=default.target (omitted for encrypted services without keyring backend)
PodmanArgs= for security settings (privileged, capabilities, devices)
Volume= for named volumes and plain bind mounts
Environment= / EnvironmentFile= for env vars
ExecStartPost= / ExecStopPost= for tunnel commands
Service name: charly-<image>.service. Container name: charly-<image>. Entrypoint: determined by the embedded init: vocabulary for the configured init system. Encrypted volumes are mounted via ExecStartPre=charly config mount in the quadlet, which creates transient charly-enc-<image>-<volume>.scope units for each encrypted volume. These scope units are independent of the container service — they survive stop/restart (see /charly-automation:enc). With Secret Service backend: auto-starts after login (ExecStartPre waits for keyring unlock, TimeoutStartSec=0). With KeePass or no backend: requires charly start (no WantedBy=default.target).
Security in Quadlet
Layer and image-level security settings become PodmanArgs= in the quadlet file:
privileged: true -> PodmanArgs=--privileged
cap_add -> PodmanArgs=--cap-add=<CAP>
devices -> PodmanArgs=--device=<DEV>
security_opt -> PodmanArgs=--security-opt=<OPT>
Source: charly/security.go, charly/quadlet.go.
Image Transfer
When engine.build=docker, charly config auto-detects if the image is missing from podman and transfers via docker save | podman load. charly update re-transfers if needed.
Source: charly/quadlet.go (generation), charly/commands.go (command structs).
Tunnel Configuration
Expose services outside the container host via tunnels. Tunnel config lives exclusively in charly.yml — it is NOT in charly.yml or OCI image labels. charly config setup persists tunnel config automatically via saveDeployState.
Tailscale Serve (tailnet-private, default)
Exposes a port to your Tailscale network only. No FQDN needed -- Tailscale handles TLS automatically. Any port works for tailnet-only serve.
tunnel: tailscale
tunnel:
provider: tailscale
private: all
bind_address must be 127.0.0.1 (the default). Setting 0.0.0.0 causes the container to bind on the Tailscale interface, preventing Tailscale from intercepting TLS. Result: HTTPS fails with wrong version number.
Port form in charly.yml. The canonical form is bare H:C (e.g. 8888:8888); charly config prepends 127.0.0.1: automatically when a tunnel is set. The IP-prefixed form 127.0.0.1:8888:8888 (and IPv6 [::1]:8888:8888) is also accepted — the canonical ParsePortMapping helper in charly/ports.go normalizes both shapes to a single-prefixed PublishPort= line, so neither form produces a doubled 127.0.0.1:127.0.0.1:8888:8888 quadlet. Unparseable port strings are logged loudly to stderr rather than silently dropped (a silent skip would otherwise suppress the entire ExecStartPost=tailscale serve block when even one port couldn't be parsed).
Tailscale Funnel (public internet)
Exposes a port to the public internet via Tailscale's edge network. Funnel restricts HTTPS ports to: 443, 8443, 10000.
tunnel:
provider: tailscale
public: [8080]
Cloudflare Tunnel
Routes traffic through Cloudflare's network. Requires fqdn. Prerequisite: cloudflared tunnel login (one-time auth).
tunnel:
provider: cloudflare
port: 3001
tunnel: my-tunnel
fqdn: "app.example.com"
charly config handles the full tunnel lifecycle automatically:
- Creates the Cloudflare tunnel (
cloudflared tunnel create) if it doesn't exist
- Writes the tunnel config YAML (
~/.config/charly/tunnels/<name>.yml) with ingress rules
- Routes DNS with
--overwrite-dns (creates or updates CNAME to tunnel)
- Generates a companion systemd service (
charly-<image>-tunnel.service)
- Enables the tunnel service and adds
Wants= to the container quadlet
charly start then starts both the container and the tunnel service together.
Backend Schemes
Port protocols declared in charly.yml control the backend URL scheme used by tunnel commands. The protocol flows from layer → OCI label (ai.opencharly.port_proto) → tunnel command.
Tailscale serve/funnel schemes:
| Scheme | Target URL | Tailscale flag | Use case |
|---|
http (default) | http://127.0.0.1:PORT | --https | Plain HTTP backends |
https | https://127.0.0.1:PORT | --https | HTTPS with valid cert |
https+insecure | https+insecure://127.0.0.1:PORT | --https | HTTPS with self-signed cert (e.g., Traefik) |
tcp | tcp://127.0.0.1:PORT | --tcp | Raw TCP forwarding |
tls-terminated-tcp | tcp://127.0.0.1:PORT | --tls-terminated-tcp | TLS-terminated TCP |
Cloudflare tunnel schemes:
| Scheme | Ingress service | Use case |
|---|
http (default) | http://localhost:PORT | HTTP origins |
https | https://localhost:PORT | HTTPS origins (use with noTLSVerify) |
tcp | tcp://localhost:PORT | Raw TCP (requires client-side cloudflared) |
ssh | ssh://localhost:PORT | SSH tunneling |
rdp | rdp://localhost:PORT | RDP streaming |
smb | smb://localhost:PORT | SMB/CIFS file sharing |
UDP ports are never tunneled — a warning is printed. UDP traffic works directly between tailnet nodes.
charly box validate checks port schemes against provider capabilities. For example, ssh is valid for Cloudflare but not Tailscale; tls-terminated-tcp is valid for Tailscale but not Cloudflare.
See /charly-image:layer for port protocol syntax in charly.yml.
Multi-Port Tailscale Serve
When private: all or ports: all, every image port gets its own tailscale serve command with scheme-appropriate flags:
tunnel:
provider: tailscale
private: all
tailscale serve --bg --https=PORT http://127.0.0.1:PORT (http, default)
tailscale serve --bg --https=PORT https+insecure://127.0.0.1:PORT (https+insecure)
tailscale serve --bg --tcp=PORT tcp://127.0.0.1:PORT (tcp)
tailscale serve --bg --tls-terminated-tcp=PORT tcp://127.0.0.1:PORT (tls-terminated-tcp)
Quadlet generates multiple ExecStartPost= and ExecStopPost= lines. Requires tailscale set --operator=$USER for non-root access.
Port protocols are stored in the ai.opencharly.port_proto image label so deploy-mode commands work without access to the original layer definitions. Remote refs require charly box pull first — see /charly-build:pull.
Instance Tunnel Inheritance
Critical: When deploying instances with charly config setup -i <name>, tunnel config is NOT auto-inherited from the base image's charly.yml entry. Each instance must have its own tunnel: section in charly.yml. Without it, the generated quadlet will have no ExecStartPost=tailscale serve commands and the instance will be unreachable via Tailscale.
Root cause: labels.go:238 deliberately skips parsing the ai.opencharly.tunnel OCI label — tunnel is charly.yml-only. When charly config setup creates a new instance, it writes ports/env/security to charly.yml but does not copy tunnel from the base entry.
Workaround: After charly config setup -i <name>, manually edit ~/.config/charly/charly.yml to add tunnel: {provider: tailscale, private: all} to the instance entry, then re-run charly config setup -i <name> to regenerate the quadlet.
Resolution
tunnel inherits from defaults (image -> defaults -> nil). The shorthand tunnel: tailscale defaults to private: all (all ports on tailnet). The shorthand tunnel: cloudflare defaults to public: all.
Source: charly/tunnel.go (resolution + the shared schemeTarget/tailscaleFlag/isTCPFamily helpers), charly/tunnel_plugin.go (the core adapter forwarding TunnelStart/TunnelStop/cloudflareTunnelSetup to verb:tunnel), candy/plugin-tunnel (the externalized tailscale/cloudflared EXECUTION leg — C16b), charly/quadlet.go (systemd integration). Tunnel config is a deploy-time concern (charly.yml only) — not validated at image-validate time.
charly.yml — Source of Truth
~/.config/charly/charly.yml is the source of truth for per-machine deployment configuration (not checked into git). All deployment commands read from image labels + charly.yml — no charly.yml needed.
How it gets populated
charly config automatically persists: workspace, ports, env (CLI -e), env_file, network, security (auto-detected devices), volume backing (--bind/--encrypt)
charly bundle import merges pre-provisioned config (tunnel, volumes, DNS) from files
charly remove cleans the entry (use --keep-deploy to preserve for re-config)
Legacy-schema rejection
The per-host config is node-form: top-level name-first <name>: {<substrate-kind>: …, <child-nodes>} entries (the substrate-kind being pod/vm/k8s/local/android/group, projected internally into the BundleConfig.Bundle map), preceded by a version: stamp and an optional provides: directive. LoadBundleConfig reads it through the unified node-form loader (LoadUnified → ProjectBundleConfig). A host still on a legacy root shape — the obsolete image:/images: root key, or the legacy deploy.yml filename — is detected and fails loud, so a misparse can never silently drop a volume: entry with type: encrypted from loadEncryptedVolumes and quietly void the encryption guarantee; the failure points at charly migrate.
charly status surfaces this as a non-fatal warning (graceful degradation falls back to image-label-driven display); the strictly-charly.yml-driven verbs (charly bundle show, charly config status, charly start) hard-fail. Run charly migrate to convert in place — it backs the original up to <file>.bak.<unix-ts> and rewrites to the latest schema. See /charly-build:migrate "charly migrate".
Structure
version: 2026.144.1443
my-app:
pod:
image: my-app
dns: "app.example.com"
env_file: "/home/user/project/.env"
network: charly
engine: podman
my-app-tunnel:
tunnel:
provider: cloudflare
port: 2283
my-app-volume:
volume:
- name: data
type: bind
host: "~/data/myapp"
- name: workspace
type: bind
host: /home/user/project
path: /workspace
- name: secrets
type: encrypted
my-app-port:
port:
- "2283:2283"
my-app-env:
env:
- "LOG_LEVEL=debug"
my-app-security:
security:
devices:
- /dev/dri/renderD128
Per-deploy fields: the substrate node (pod/vm/k8s/local/android/group) holds scalars (image: for a pod, from: to inherit a same-kind template, disposable, lifecycle, description, dns, fqdn, acme_email, env_file, network, engine); every non-scalar collection becomes a <name>-<key> child node (tunnel, volume, port, env, security, secret, add_candy, install_opts).
The substrate node, add_candy:, and install_opts: fields
The charly bundle add/del surface honors these per deploy, each only when relevant to the resolved target.
The substrate node — pod: (the default, carrying image:), vm:, k8s:, local:, or android: (carrying from:) selects the target; there is no target: field. A local deploy carries the Ansible-style host: scalar (host: local, the default, runs directly; host: <user@machine> runs over SSH) and applies its add_candy: candies to the host filesystem.
add_candy: — a <name>-add_candy child node listing extra layer refs applied on top of the image's base layers. Each entry accepts the same 4 ref forms as the command-line --add-candy flag (local name / local YAML path / remote github.com/.../candy/<n>[@ref]). See "add_candy: overlay mechanism" above for pod vs local semantics.
install_opts: — a <name>-install_opts child node of local-target defaults that mirror the CLI flags on charly bundle add. CLI flags win on conflict; the deploy provides defaults so you don't have to repeat --with-services --allow-repo-changes on every invocation.
my-host:
local:
from: fedora-coder
host: local
my-host-add_candy:
add_candy:
- my-team-vimrc
- github.com/team-acme/configs/candy/sshkeys
- ./private-overlay.yml
my-host-install_opts:
install_opts:
with_service: true
allow_repo_changes: true
allow_root_tasks: false
skip_incompatible: false
verify: true
builder_image: fedora-builder:2026.04
my-host-env:
env:
OPENCHARLY_DEV: "true"
Fields ignored on pod deploys: install_opts (local-only). Fields ignored on local deploys: volume, port, tunnel, sidecar, security's container-runtime bits.
Resource Caps
Cgroup memory and CPU limits are stored in the security: block of charly.yml and persist across charly config re-runs (a --memory-max flag applied once stays in effect until explicitly changed). The fields are:
selkies-desktop:
pod:
image: selkies-desktop
selkies-desktop-security:
security:
shm_size: "1g"
memory_max: "6g"
memory_high: "5g"
memory_swap_max: "2g"
cpus: "4.0"
Merge semantics (authoritative, from charly/security.go):
| Source | Merge rule |
|---|
| Layer → layer | Smallest value wins (tightest cap is the safer default) |
Layers → image-level security: in charly.yml | Box-level replaces the merged layer value |
Box-level → deploy-level security: in charly.yml | Deploy-level replaces the image-level value |
| CLI flag → deploy-level | CLI flag writes directly to charly.yml (--memory-max=... on charly config) |
Quadlet emission ([Service] section of .container file):
memory_max → MemoryMax=6G (lowercase g is auto-normalized to G because systemd parses lowercase as infinity — see /charly-selkies:chrome gotcha)
memory_high → MemoryHigh=5G
memory_swap_max → MemorySwapMax=2G
cpus → CPUQuota=400% (systemd percentage form: 1 core = 100%)
Direct-mode emission (podman run flags, for engine.run=direct): --memory, --memory-reservation, --memory-swap, --cpus. SecurityArgs in charly/security.go emits both forms from the same source of truth.
Unset fields pass through — setting --memory-max=6g alone will not wipe an existing shm_size from charly.yml. Only the fields you pass on the CLI get overwritten; everything else is preserved from the current charly.yml state.
Canonical consumer: the chrome candy's cgroup caps. See /charly-selkies:chrome (Resource Caps) — the caps bound a Chrome crash loop's blast radius; a wedged loop (orphan memfd shmem) is cleared by restarting the container, which tears down the cgroup. See /charly-infrastructure:supervisord (Event Listeners) for the eventlistener pattern in general and /charly-image:layer (Security Declaration) for the authoring side.
Provides (Top-Level)
The provides: section holds all resolved env and MCP provides entries from deployed images. Managed automatically by charly config when images with env_provide or mcp_provide layers are deployed.
provides:
env:
- name: OLLAMA_HOST
value: http://charly-ollama:11434
source: ollama
- name: PGHOST
value: charly-postgresql
source: postgresql
mcp:
- name: jupyter
url: http://charly-jupyter:8888/mcp
transport: http
source: jupyter
my-app:
pod:
image: my-app
provides.env: — resolved env_provide entries with {name, value, source} (self-excluded per consumer)
provides.mcp: — resolved mcp_provide entries with {name, url, transport, source} (pod-aware, no self-exclusion)
source tracks which image injected each entry — used for cleanup on charly remove
- Priority for env (last wins): provides.env < per-image deploy env < deploy env_file < workspace .env < CLI --env-file < CLI -e
charly config remove / charly remove automatically cleans up entries from the removed image
- Instance-aware cleanup: removing an instance (e.g.,
charly remove selkies-desktop -i work) only cleans provides entries sourced from that specific instance (selkies-desktop/work), not from other instances of the same base image. Base image removal requires no other instances to exist before cleaning provides
See /charly-image:layer for env_provide/mcp_provide field declarations and /charly-core:charly-config for --update-all propagation.
Secrets
Per-deployment secret source overrides. Secrets declared in image labels (from charly.yml) are provisioned as Podman secrets at charly config time. A deploy's secret: child node can override where the value comes from:
my-app-secret:
secret:
- name: api-key
source: keyring
If no source is specified, the credential resolution chain is used: env var > keyring > config file.
Workspace recall
Volume binding is configured at deploy time via --bind flags. The binding is persisted in charly.yml:
charly config my-app --bind workspace=~/project
charly remove my-app --keep-deploy
charly config my-app
Deploy status audit
charly bundle status
Labels-only architecture
Deployment commands (charly config, start, status, logs, update, remove, seed, service) resolve all configuration from OCI image labels + charly.yml — no charly.yml dependency. This means you can deploy on any machine with just charly box pull + charly config.
Local-storage requirement. Because deploy-mode commands read OCI labels directly from local container storage (via ExtractMetadata → podman inspect), the image must be pulled first. If it isn't, the command fails with ErrImageNotLocal and the CLI suggests charly box pull. See /charly-build:pull for the sentinel pattern and remote-ref (@github.com/...) handling.
MergeDeployOntoMetadata ordering gotcha. When extending deploy-mode code, remember that deploy-overlay fields like meta.Tunnel are nil until MergeDeployOntoMetadata runs. A if meta.Tunnel != nil check before the merge is unreachable code — this was the actual bug fixed in start.go by the refactor.
Instance Support
Deploy multiple containers of the same image with -i <instance>:
charly config selkies-desktop -i work -e TS_HOSTNAME=work -p 3001:3000
charly config selkies-desktop -i personal -p 3002:3000
charly start selkies-desktop -i work
charly start selkies-desktop -i personal
Deploy key convention: Base images use selkies-desktop as the charly.yml key. Instances use selkies-desktop/work (slash-separated). Functions: deployKey() constructs keys, parseDeployKey() splits them back. Source: charly/deploy.go.
Container naming: charly-<image>-<instance> (e.g., charly-selkies-desktop-work). Quadlet file: charly-selkies-desktop-work.container.
Deploy structure with instances:
selkies-desktop:
pod:
image: selkies-desktop
selkies-desktop-port:
port: ["3000:3000"]
selkies-desktop/work:
pod:
image: selkies-desktop
selkies-desktop-work-port:
port: ["3001:3000"]
selkies-desktop-work-env:
env: ["TS_HOSTNAME=work"]
selkies-desktop/personal:
pod:
image: selkies-desktop
selkies-desktop-personal-port:
port: ["3002:3000"]
Instance lifecycle: All commands accept -i: charly start/stop/status/logs/remove <image> -i <instance>, charly bundle show/reset <image> -i <instance>. Removing an instance only cleans its charly.yml entry — the base and other instances are unaffected. Provides cleanup waits until the last entry for a base image is removed.
Instance removal gotcha: charly config remove disables the systemd service but does NOT remove the charly.yml entry. You MUST also run charly bundle reset <image> -i <instance> and delete the quadlet file. If you run charly config --update-all before cleaning charly.yml, stale quadlet files will be re-created. See /charly-core:charly-config for the full 3-step cleanup workflow.
MCP name disambiguation: When an instance provides MCP servers, the server name gets -<instance> appended (e.g., chrome-devtools-work). See /charly-core:charly-config for details.
Volume Backing
Layers declare what persistent storage they need via volume: in charly.yml. By default, all volumes are Docker/Podman named volumes. At charly config time, any volume's backing can be changed to a host bind mount or encrypted gocryptfs mount.
Per-Volume Configuration via charly config
charly config immich
charly config immich --bind import --bind external
charly config immich --bind library=/mnt/nas/photos
charly config immich --encrypt library
charly config immich -v library:bind:/mnt/nas -v import:bind -v cache:encrypted
CHARLY_VOLUMES_IMMICH="library:bind:/mnt/nas,import:bind" charly config immich --password auto
charly.yml Volume Config
Volume backing choices are persisted in charly.yml as a <name>-volume child node:
immich-volume:
volume:
- name: library
type: bind
host: "/mnt/nas/photos"
- name: import
type: bind
- name: cache
type: encrypted
Fields:
name: matches a layer-declared volume name
type: volume (default, named volume), bind (host directory), encrypted (gocryptfs)
host: explicit host path — for bind type (optional, omit for auto path); for encrypted type, the direct volume directory containing cipher/ and plain/ (optional, omit to use global encrypted_storage_path with charly-<image>-<name> prefix)
path: container path (only for deploy-only volumes not declared in any layer)
data_seeded: bool — tracks whether data from image data candies was provisioned (set by charly config)
data_source: string — image:tag that provided the data (updated by charly config and charly update)
Auto path: When type: bind and no host is specified, the host path is computed at runtime: <volumes_path>/<image>/<name>. Default volumes_path: ~/.local/share/charly/volumes/. Configurable: charly settings set volumes_path /mnt/nas/charly (env: CHARLY_VOLUMES_PATH).
Unconfigured volumes remain named volumes — no charly.yml entry needed.
Resolution Flow
ResolveVolumeBacking() in charly/deploy.go splits image volumes into named volumes and bind-backed mounts:
- Load all volumes from image labels (
ai.opencharly.volume)
- Load charly.yml volume overrides for this image
- For each declared volume:
- If charly.yml says
type=bind → host bind mount (explicit path or auto path)
- If charly.yml says
type=encrypted → gocryptfs FUSE mount
- Otherwise → named volume (Docker/Podman-managed)
- Deploy-only volumes (with
path: set, not in any layer) are also supported
Integration
- Data provisioning:
charly config automatically provisions data from data candies into bind-backed volumes (via --seed, default true). charly update merges new data non-destructively. See /charly-core:charly-config and /charly-core:charly-update
charly shell/charly start: resolves volume backing, verifies bind dirs exist and encrypted volumes are mounted, generates -v flags
charly config (quadlet): bind-backed volumes become Volume= lines with host paths. --userns=keep-id added when bind-backed volumes exist
charly remove --purge: removes named volumes
charly box inspect --format bind_mounts: outputs deploy-configured volume backing
Source: charly/deploy.go (DeployVolumeConfig, ResolveVolumeBacking), charly/enc.go (ResolvedBindMount).
VNC Password for Deployments
For images with wayvnc (VNC on tcp:5900), provision a VNC password after enabling.
Store the password in the VNC credential store (retained host-side), then configure
server-side auth (VeNCrypt/TLS) by running the candy's vnc: passwd step:
charly settings set vnc.password.sway-browser-vnc mysecret
charly config sway-browser-vnc
charly check live sway-browser-vnc --filter vnc
See /charly-check:vnc for full VNC authentication documentation.
Port Relay Pattern
Some services (OpenClaw) bind only to loopback for security. The port_relay field in charly.yml creates a socat relay from the container's network interface to loopback, making the service accessible externally without weakening its security model.
openclaw:
pod:
image: openclaw
openclaw-port:
port:
- 18789
openclaw-port_relay:
port_relay:
- 18789
Requires the socat layer as a dependency. The relay runs as a relay-<port> service in the configured init system. See /charly-openclaw:openclaw for an example.
Chrome CDP exception: Chrome DevTools no longer uses port_relay. Chrome 146+ rejects connections with non-localhost Host headers, so a simple socat relay is insufficient. Instead, Chrome uses a cdp-proxy Python supervisord service that listens on 0.0.0.0:9222, forwards to Chrome on 127.0.0.1:9223 with Host header rewriting, and rewrites response URLs (e.g., webSocketDebuggerUrl) with Content-Length correction. See /charly-selkies:chrome and /charly-check:cdp for details.
Provides Configuration
Global environment and MCP server injection for all deployed images. Stored in charly.yml under provides:.
Structure
provides:
env:
- name: OLLAMA_HOST
value: http://charly-ollama:11434
source: ollama
mcp:
- name: jupyter
url: http://charly-jupyter:8888/mcp
transport: http
source: jupyter
provides.env: — resolved env_provide entries with {name, value, source}
provides.mcp: — resolved mcp_provide entries with {name, url, transport, source}
source field tracks which image contributed each entry (used for cleanup on charly remove)
- Entries resolved at
charly config time from layer env_provide: and mcp_provide: declarations
GlobalEnvForImage() in provides.go resolves both env and MCP provides for each consumer image
- Env provides: self-excluded (prevents own env_provide from overriding service bind addresses)
- MCP provides: pod-aware (same-container entries resolve to
localhost, no self-exclusion)
- Consumer containers receive
CHARLY_MCP_SERVERS JSON env var with resolved MCP server entries
See /charly-core:charly-config for setup workflow and /charly-image:layer for declaration format.
Sidecar Pod Deployment
When sidecars are attached via charly config --sidecar <name>, deployment generates a Podman pod instead of a standalone container. See /charly-automation:sidecar for full sidecar documentation.
Generated Files
| File | Content |
|---|
charly-<image>.pod | Pod: Network=charly, PodmanArgs=-p (ports), --shm-size |
charly-<image>-<sidecar>.container | Sidecar: image, env, caps, devices, secrets |
charly-<image>.container | App: Pod=charly-<image>.pod, no ports/network |
The pod owns the shared network namespace. Port mappings and ShmSize move from the container to the pod. The app container gets Pod= and loses PublishPort= and Network=.
Dual Networking (Tailscale)
When a Tailscale sidecar is attached, the pod has dual networking:
- "charly" bridge — container-to-container connectivity,
env_provide discovery
- Tailscale tun — exit node routing for outbound internet traffic
--exit-node-allow-lan-access exempts bridge subnets from the tunnel
Host tunnel: tailscale (ExecStartPost=tailscale serve) and the sidecar are independent: the host tunnel serves ports on the host's tailnet, while the sidecar handles exit node routing on a potentially different tailnet.
charly.yml Sidecar Config
selkies-desktop:
pod:
image: selkies-desktop
selkies-desktop-sidecar:
sidecar:
tailscale:
env:
TS_HOSTNAME: selkies-desktop
TS_EXTRA_ARGS: "--exit-node=100.80.254.4 --exit-node-allow-lan-access"
Preemptible resource arbitration (preemptible / requires_exclusive / requires_shared / charly preempt)
A physical host resource can be held by only ONE deployment at a time — the
canonical case is a GPU passed through to a VM via VFIO. The resource arbiter
(charly/preempt.go) frees such a resource on demand and gives it back.
gpu-workstation:
vm:
from: gpu-workstation-vm
gpu-workstation-preemptible:
preemptible:
holds: [nvidia-gpu]
stop: shutdown
restore: always
check-gpu-bed:
vm:
from: gpu-check-vm
disposable: true
check-gpu-bed-requires_exclusive:
requires_exclusive: [nvidia-gpu]
- When the arbiter acts. Before a claimant is brought up —
charly check run <bed>
(transient claim, auto-released at teardown), or a standalone charly vm create /
charly start (persistent claim, released on charly vm stop/vm destroy/charly stop/
charly remove) — it gracefully stops every running preemptible holder whose
holds: intersects the claimant's requires_exclusive:, waits for it to
actually power off (so the resource is truly released), records a crash-safe
lease, then proceeds. Nested charly subprocesses inherit the lease
(CHARLY_PREEMPT_LEASE) and never re-acquire.
requires_shared: — the REFCOUNTED claimant (mutually exclusive with
requires_exclusive: on one node, per validate_preempt.go). Many concurrent
requires_shared: claimants share a token, so a GPU-CDI pod declares
requires_shared: [nvidia-gpu] and the arbiter flips the whole IOMMU group
vfio→nvidia+CDI at the first claimant's deploy and back nvidia→vfio only
when the LAST claimant releases — the group-aware modprobe-gated switch
(charly vm gpu mode). A preemptible holder whose holds: intersects a shared
claim is still preempted first. Full GPU-mode-switch model: /charly-vm:vm +
/charly-internals:disposable; the bed surface: /charly-check:check.
- Standing authorization — preempt autonomously. You may trigger preemption
(bring up a
requires_exclusive: claimant, stopping a running preemptible
holder) WITHOUT per-run operator confirmation. Preemption is reversible by design
(graceful stop + guaranteed restore), so it is exempt from the
confirm-before-destroy rule that governs irreversible teardown of a
non-preemptible resource. See /charly-internals:disposable "Standing
authorization".
- Token = a name, not a mechanism — operator-chosen (
nvidia-gpu), decoupled
from how each side reaches it (VM hostdev vs pod --device); pure
set-intersection unifies pod-vs-VM contention.
- GPU auto-allocation — when a token ALSO carries an embedded
resource:
gpu: selector (resource: {nvidia-gpu: {gpu: {vendor: "0x10de"}}}), a
VM-targeted claimant's charly vm create auto-detects the matching GPU,
persists its <hostdev> into the per-host instance.yml, and injects it — or
FAILS HARD when no matching card exists. Operator-authored hostdevs win (no
double-inject). See /charly-internals:disposable "resource-arbitration axis"
/charly-build:build resource:.
charly preempt status lists active leases + flags STRANDED ones (claimant
gone). charly preempt restore [claimant] reconciles stranded leases (also run
automatically at the next acquire) / force-releases a named one. A holder is
NEVER left permanently stopped — the ledger
(~/.local/share/charly/preemption/leases.yml) is written before any stop, and
restore = "start every listed holder that isn't running".
- Orthogonal to disposable/ephemeral — no derivation either way; a deploy may
be both preemptible and disposable. Full reference:
/charly-internals:disposable
"The resource-arbitration axis".
Sibling members — companion deployments brought up alongside
A resource node placed directly under a deploy is a sibling member — a
companion deployment brought up ALONGSIDE it on the shared charly network
(siblings, not children). Contrast deploy-into nesting (a resource node placed
under another resource), whose children run inside that node's venue and
are addressed by a dotted path (parent.child). Membership is tree position —
there is no peer: or nested: field. A member is a companion instrument: the
canonical case is a Chrome DRIVER pod that CDP-probes a SEPARATE web-server
SUBJECT, where the driving probe is a check step placed under the driver member and
addresses the subject via ${HOST:<subject>} (see /charly-check:check
"Cross-deployment probing"). The SAME tree position + lifecycle serve a disposable
check deploy and an operator deploy — one codebase.
webapp:
pod:
image: web
chrome:
pod:
image: chrome-headless
chrome-step-render:
check: cdp renders the web subject over the shared net
cdp: text
url: http://${HOST:webapp}:8080
- Folded to addressable top-level entries. At load time
foldMembers reads each
tree-position member into the internal .Members map and registers it as a top-level
Bundle entry (so charly config <member> / charly start <member> resolve it
through the exact path any deploy uses) carrying a derived MemberOf: <owner>. A
member name must be globally unique (a collision with any deploy/bed/member is
a hard load error) and carry no . (same rules as nested-member keys + bed
names); the author keeps member host ports disjoint (the loader does not check
ports — port absence, i.e. auto-allocation, avoids fixed-port collisions).
- One shared lifecycle (R3).
bringUpMembers / tearDownMembers (charly/bundle_members.go)
bring members up after the owner and tear them down with it, by shelling out to the
SAME verbs — a pod member via charly config + charly start (+ readiness wait), a
non-pod member via charly bundle add / charly bundle del. Wired into charly bundle add /
charly bundle del (operator path) AND the disposable-deploy bed runner (the bed's
--node-only add never double-deploys; the runner brings members up after the root
starts). A bed's charly update (destroy + rebuild) tears members down and back up too.
- Disposability is inherited, never invented.
foldMembers promotes a member to
disposable: true only when its OWNER is disposable (so a disposable bed's
rebuild is authorized to tear the member down); a member of a non-disposable operator
deploy stays non-disposable. No new autonomy is granted — members are components of
their owner, touched only by the owner's explicit add/del/update (R6,
/charly-internals:disposable).
- Members are NOT check-live'd. A disposable check deploy evaluates its SUBJECT
(root + any nested members via
bedCheckLiveRefs); sibling members are instruments,
never evaluated themselves — the subject's checks placed under a member drive
through it.
- Addressing the subject from a driven probe uses the unified
${HOST:...} variable —
${HOST:<member>} (no :<port> → container DNS) or ${HOST:<member>:<port>} (with
:<port> → host-reachable endpoint) — see /charly-check:check "Cross-deployment
probing".
Cross-References
Deploy surface:
/charly-local:local-deploy — Local-target execution model: the external deploy:local plugin, ledger, gates, 15 ReverseOp kinds, sudo batching
/charly-internals:install-plan — The InstallPlan IR shared by the deploy targets: ALL FIVE substrates (local/vm/pod/k8s/android) are external out-of-process deploys via plugins over the executor reverse channel — local/vm consume the IR via kit.WalkPlans (vm's walk runs inside the guest); pod's plugin walks nothing — its lifecycle hook builds the overlay host-side via the uniform F10 overlay host-builder (the retained PodDeployTarget engine — add_candy: synthesis via OCITarget); charly box build itself emits via the separate writeCandySteps → emitTasks generator, not the IR
/charly-internals:local-infra — Supporting Go files for local deploys: hostdistro, ledger, builder_run, shell_profile, reverse_ops, service_render, deploy_ref
Deploy-adjacent commands:
/charly-build:pull — Prerequisite: fetch the image into local storage; handles remote refs (@github.com/...) and the ErrImageNotLocal recovery path
/charly-automation:sidecar — Sidecar containers, pod networking, Tailscale exit nodes, Environment Contract (provides filtering)
/charly-core:service — Service lifecycle (start/stop/update/remove)
/charly-core:start — Ergonomic alias for charly bundle add <image> <image> (container target)
/charly-core:stop — Ergonomic alias for charly bundle del <name>
/charly-core:charly-update — Per-instance update pattern; equivalent to charly bundle add <name> --pull
/charly-core:charly-config — Resource cap flags (--memory-max/high/swap/cpus), provides filtering, env_require enforcement, NO_PROXY auto-enrichment, --sidecar, -i instance support, MCP name disambiguation
/charly-automation:enc — Encrypted storage commands (charly config mount/unmount)
/charly-check:vnc — VNC password setup for desktop containers
/charly-vm:vm — Virtual machine deployment (charly vm)
/charly-build:build — Building images before deployment (+ the --no-cache intermediate scratch-stage caveat)
/charly-build:charly-mcp-cmd — verify the MCP endpoints declared by provides.mcp: entries are actually reachable (charly check live <image> --filter mcp); note the port-publishing gotcha when a port: override in charly.yml predates a newly-added mcp-providing layer
/charly-image:image — Image configuration, OCI label emission, labels.go:238 tunnel read-skip
/charly-image:layer — Unified service: schema (use_packaged + structured custom), env_provide/env_require/env_accept field declarations, security resource caps
/charly-check:check — Local check: in charly.yml overlays image-baked deploy defaults: entries with matching id: replace, otherwise append. id: X, skip: true disables a baked check without a replacement.
Canonical layer worked examples:
/charly-selkies:chrome — cgroup resource-caps consumer (caps bound a Chrome crash loop)
/charly-infrastructure:supervisord — Event listener pattern triggered by the caps; ServiceSchemaDef that renders service: entries to supervisord INI
/charly-infrastructure:postgresql — Canonical use_packaged: entry (packaged unit reuse)
/charly-ollama:ollama, /charly-hermes:hermes — Custom service: entries
/charly-selkies:selkies-labwc — Multi-instance proxy deployment, tunnel inheritance workaround
Cross-kind name reuse + ResolveDeployRef precedence
A deploy's name lives alongside every other top-level entity in its document. Cross-kind reuse of a name across SEPARATE discovered files is fine (a candy/redis + a box/redis resolve to distinct internal maps), but two top-level entities WITHIN one document MUST NOT share a name — they would collide on one YAML key (charly box validate flags it). The convention is to keep the user-facing deploy name and suffix the template it deploys: this repo's charly-cachyos deploy carries local: {from: charly-cachyos-app}, referencing the distinct charly-cachyos-app kind:local template. A deploy's substrate node (pod:, vm:, local:, k8s:, android:) is scoped to the matching kind, no fall-through.
ResolveDeployRef (used by charly bundle add <name> <ref>) is box-first: when a name exists as BOTH a box and a candy, the box wins for the primary <ref> positional. The --add-candy <ref> path goes through ResolveDeployRefAsCandy, which is candy-first. A same-name box and candy (in separate files) is permitted.
The loader raises a hard load-time error on obsolete deploy names (the retired qc / cachyos-dx keys) and on any obsolete root shape (a kind: deployment / deployment: / kind: deploy / deploy: / bundle: document — the kind is now the name-first substrate-node (pod:/vm:/k8s:/local:/android:/group:) discriminator); every such error points at charly migrate. See /charly-build:migrate.
When to Use This Skill
MUST be invoked when the task involves quadlet generation, tunnels, bind mounts, or deploy overlays. Invoke this skill BEFORE reading source code or launching Explore agents.
Workflow position: After /charly-build:build, before /charly-core:service.
Previous step: /charly-build:build (build the image). Next step: /charly-core:service (start, status, logs).
Live-deploy verification is mandatory (see /charly-check:check 10 standards)
Changes that touch this verb's output must reach a healthy deployment on a target explicitly marked disposable: true (see /charly-internals:disposable). Use charly update <name> to destroy + rebuild unattended on any disposable target. Never experiment on a non-disposable deploy — set up a disposable one first with charly bundle add <name> <ref> --disposable or mark a VM in vm.yml.
After committing the source-level fix, charly update the disposable target ONCE MORE from clean and re-run the full verification. A fix that passes only on a hand-patched target is not a real fix — it's a regression waiting for the next unrelated rebuild. Paste BOTH the exploratory-pass output and the fresh-rebuild-pass output into the conversation.
Unit tests + a clean compile are necessary but not sufficient. See CLAUDE.md R1–R10.