core/skills/deploy/SKILL.md
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.
npx skillsauth add overthinkos/overthink-plugins deployInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.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.BundleNode.Disposable (the disposable: scalar under the substrate node) is the sole source of truth.<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: 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".charly bundle is the parent verb for applying and tearing down deployments, plus managing charly.yml overrides. The command family has two distinct surfaces:
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.charly bundle show/export/import/reset/path/status. Read and mutate ~/.config/charly/charly.yml itself.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.
| 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.
add / delcharly 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 flagsUniversal:
--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 preflightcharly 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.
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 # provision VM first
charly bundle add vm:arch ripgrep # then apply layer in guest
charly bundle add vm:arch fedora-coder \
--add-candy team-extras \
--add-candy github.com/team/configs/candy/sshkeys
charly bundle del vm:arch # reverse all applied layers (VM stays up)
VmDeployState schema in charly.ymlWhen charly bundle add vm:<name> completes, a vm_state child node lands in the charly.yml deploy:
arch: # the deploy name; addressable as vm:arch
vm:
from: arch # inherit the vm template (implies the VM target)
arch-vm_source:
vm_source: # persisted copy of VmSpec.Source for re-apply
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-... # stable UUID across rebuilds
nvram_path: "" # empty for firmware=bios
last_build: 2026-04-22T10:14:27Z
last_deploy: 2026-04-22T10:18:55Z
applied_layers: [ripgrep, sshkeys]
base_image_sha256: a8c9e0f1... # cloud_image integrity trace
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.
vm: substrate nodeA 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 targetsWhen 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.
install_opts: fieldsinstall_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.
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 mechanismBoth container and host targets accept extra candies at deploy time via --add-candy <ref> (repeatable) or the deploy's add_candy: child node. Semantics:
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.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).
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:
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: # base instance — name == image (just convention)
pod:
image: versa
versa/ecovoyage: # `<base>/<instance>` deploy name
pod:
image: versa # SAME image; explicit (no inference)
versa-ecovoyage-volume:
volume:
- name: workspace
type: bind
host: /home/user/projects/ecovoyage
versa-ecovoyage-port:
port:
- "32718:2718" # explicit pinned ports; other ports auto-allocate
versa/another-tenant: # second instance of versa
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).
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: # arbitrary deploy name
pod:
image: versa # short name → resolves to current build
versa-pinned-2026.131.2134: # pinned-version deploy
pod:
image: ghcr.io/overthinkos/versa:2026.131.2134 # explicit ref, never re-resolved
versa-canary: # canary deploy on a rolling tag
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).
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).image: value is either:
versa) — resolved against the candy:
image entries (those carrying base:/from:) in charly.yml to the currently-built tag, ORghcr.io/overthinkos/versa:2026.131.2134 or
…@sha256:…) — pinned to that exact image, never re-resolved.
Use this for version pinning and canary deploys./ 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.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.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"] # pin 8888 for a stable bookmark; other ports auto-allocate
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.
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.
Deploy a local image as a container:
charly bundle add my-dev fedora-coder
# Uses the my-dev deploy's child nodes for volume/port/env/tunnel.
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 bundle add fedora-coder fedora-coder (container target)
charly stop fedora-coder # == charly bundle del fedora-coder
User-level systemd services via podman quadlet. Generated by charly config.
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 mountsEnvironment= / EnvironmentFile= for env varsExecStartPost= / ExecStopPost= for tunnel commandsService 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).
Layer and image-level security settings become PodmanArgs= in the quadlet file:
privileged: true -> PodmanArgs=--privilegedcap_add -> PodmanArgs=--cap-add=<CAP>devices -> PodmanArgs=--device=<DEV>security_opt -> PodmanArgs=--security-opt=<OPT>Source: charly/security.go, charly/quadlet.go.
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).
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.
Exposes a port to your Tailscale network only. No FQDN needed -- Tailscale handles TLS automatically. Any port works for tailnet-only serve.
tunnel: tailscale
# or expanded:
tunnel:
provider: tailscale
private: all # all image ports on tailnet
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).
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] # funnel ports must be 443, 8443, or 10000
Routes traffic through Cloudflare's network. Requires fqdn. Prerequisite: cloudflared tunnel login (one-time auth).
tunnel:
provider: cloudflare
port: 3001
tunnel: my-tunnel # optional, defaults to charly-<image>
fqdn: "app.example.com"
charly config handles the full tunnel lifecycle automatically:
cloudflared tunnel create) if it doesn't exist~/.config/charly/tunnels/<name>.yml) with ingress rules--overwrite-dns (creates or updates CNAME to tunnel)charly-<image>-tunnel.service)Wants= to the container quadletcharly start then starts both the container and the tunnel service together.
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.
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.
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.
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.
~/.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.
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 filescharly remove cleans the entry (use --keep-deploy to preserve for re-config)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".
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).
add_candy:, and install_opts: fieldsThe 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 # inherit the kind: local template
host: local # present ⇒ local target
my-host-add_candy:
add_candy:
- my-team-vimrc # local layer
- github.com/team-acme/configs/candy/sshkeys # remote layer
- ./private-overlay.yml # local file
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.
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=5Gmemory_swap_max → MemorySwapMax=2Gcpus → 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.
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 removecharly config remove / charly remove automatically cleans up entries from the removed imagecharly 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 providesSee /charly-image:layer for env_provide/mcp_provide field declarations and /charly-core:charly-config for --update-all propagation.
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 # matches layer secret name
source: keyring # "keyring" (default), "env:VAR", "file:/path"
If no source is specified, the credential resolution chain is used: env var > keyring > config file.
Volume binding is configured at deploy time via --bind flags. The binding is persisted in charly.yml:
charly config my-app --bind workspace=~/project # Saves volume config to charly.yml
charly remove my-app --keep-deploy # Quadlet removed, config preserved
charly config my-app # Picks up volumes from charly.yml
charly bundle status
# sway-browser-vnc charly.yml: yes quadlet: yes (ok)
# old-service charly.yml: yes quadlet: no (stale config)
# manual-service charly.yml: no quadlet: yes (no overrides)
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.
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.
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.
charly config# Default: all volumes as named volumes (no flags needed)
charly config immich
# Configure specific volumes as bind mounts
charly config immich --bind import --bind external
# Bind mount with explicit host path
charly config immich --bind library=/mnt/nas/photos
# Configure volume as encrypted (gocryptfs)
charly config immich --encrypt library
# Canonical syntax: --volume name:type[:path]
charly config immich -v library:bind:/mnt/nas -v import:bind -v cache:encrypted
# Fully automated via env vars (no prompts)
CHARLY_VOLUMES_IMMICH="library:bind:/mnt/nas,import:bind" charly config immich --password auto
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" # explicit host path
- name: import
type: bind # no host → auto path: <volumes_path>/<image>/import
- name: cache
type: encrypted # gocryptfs managed
Fields:
name: matches a layer-declared volume nametype: 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.
ResolveVolumeBacking() in charly/deploy.go splits image volumes into named volumes and bind-backed mounts:
ai.opencharly.volume)type=bind → host bind mount (explicit path or auto path)type=encrypted → gocryptfs FUSE mountpath: set, not in any layer) are also supportedcharly 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-updatecharly shell/charly start: resolves volume backing, verifies bind dirs exist and encrypted volumes are mounted, generates -v flagscharly config (quadlet): bind-backed volumes become Volume= lines with host paths. --userns=keep-id added when bind-backed volumes existcharly remove --purge: removes named volumescharly box inspect --format bind_mounts: outputs deploy-configured volume backingSource: charly/deploy.go (DeployVolumeConfig, ResolveVolumeBacking), charly/enc.go (ResolvedBindMount).
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 # store in the VNC credential store
charly config sway-browser-vnc
# After the container starts, configure server-side auth via the vnc: passwd step:
charly check live sway-browser-vnc --filter vnc
See /charly-check:vnc for full VNC authentication documentation.
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.
# In charly.yml
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.
Global environment and MCP server injection for all deployed images. Stored in charly.yml under provides:.
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)charly config time from layer env_provide: and mcp_provide: declarationsGlobalEnvForImage() in provides.go resolves both env and MCP provides for each consumer imagelocalhost, no self-exclusion)CHARLY_MCP_SERVERS JSON env var with resolved MCP server entriesSee /charly-core:charly-config for setup workflow and /charly-image:layer for declaration format.
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.
| 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=.
When a Tailscale sidecar is attached, the pod has dual networking:
env_provide discovery--exit-node-allow-lan-access exempts bridge subnets from the tunnelHost 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.
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 / 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: # HOLDER — a long-running operator VM
vm:
from: gpu-workstation-vm
gpu-workstation-preemptible:
preemptible:
holds: [nvidia-gpu] # exclusive-resource token(s) it occupies (shorthand: preemptible: [nvidia-gpu])
stop: shutdown # graceful shutdown (default & only) — frees a VFIO device
restore: always # always (default) | on-success
# CLAIMANT (any deploy, including a disposable check deploy) that needs sole use while it runs:
check-gpu-bed:
vm:
from: gpu-check-vm
disposable: true
check-gpu-bed-requires_exclusive:
requires_exclusive: [nvidia-gpu]
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.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".nvidia-gpu), decoupled
from how each side reaches it (VM hostdev vs pod --device); pure
set-intersection unifies pod-vs-VM contention.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"./charly-internals:disposable
"The resource-arbitration axis".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: # an operator deploy + a companion member
pod:
image: web
chrome: # a SIBLING member, brought up on the shared charly net
pod:
image: chrome-headless # a full deploy (its own substrate/port/…)
chrome-step-render: # a driven probe — runs on chrome (tree position)
check: cdp renders the web subject over the shared net
cdp: text
url: http://${HOST:webapp}:8080 # address the subject member by HOST var
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).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.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).bedCheckLiveRefs); sibling members are instruments,
never evaluated themselves — the subject's checks placed under a member drive
through it.${HOST:...} variable —
${HOST:<member>} (no :<port> → container DNS) or ${HOST:<member>:<port>} (with
:<port> → host-reachable endpoint) — see /charly-check:check "Cross-deployment
probing".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_refDeploy-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 workaroundA 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.
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).
/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.
tools
Use when authoring or modifying a charly PLUGIN — a candy with a `plugin:` block that contributes Providers (verbs/kinds/deploy-targets/steps/builders/commands), its own CUE schema, builtin (compiled-in) or external (out-of-tree git repo). Covers the unified Provider model, the per-plugin CUE-schema contract (single source → Go params for dev + schema-over-Describe RPC for runtime), the SDK, and the loader.
tools
The CUE data-validation / configuration CLI (cue), pinned to v0.16.1. Use when working with the cue candy, installing the cue binary into a box or onto a target:local dev host, or running the offline schema-vendoring pipeline that feeds charly's egress validation.
tools
CUE EGRESS validation — validating (and, where it adds value, generating) the config files charly WRITES to a system BEFORE the bytes hit disk. MUST be invoked before working on charly/egress.go, the vendored schemas under candy/plugin-egress/egress-schemas/vendor/, the ValidateEgress / registerVendoredEgressKind path, the offline `task cue:vendor` pipeline, or adding an egress schema for any written artifact (cloud-init, k8s manifests, traefik routes, runtime config, install ledger, systemd/quadlet units, ssh_config, libvirt XML).
tools
Kubernetes cluster-probe declarative check verb — the `kube:` check verb (nodes, pods, ingress, storage class, addon health, apply/delete, and arbitrary resource GETs) served out-of-process by the candy/plugin-kube plugin (vendored client-go; no external kubectl required).