automation/skills/sidecar/SKILL.md
Topic skill (no dedicated `charly sidecar` command — the surface is the `--sidecar <name>` / `--list-sidecars` flags on `charly config` and the `sidecar:` field in `charly.yml`). MUST be invoked before any work involving: sidecar containers, pod networking, Tailscale exit nodes, `charly config --sidecar`, the `charly.yml` `sidecar:` field, or sidecar-env filtering (`env_accept` / `env_require` routing to the sidecar vs the app container).
npx skillsauth add overthinkos/overthink-plugins sidecarInstall 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.
Sidecars are additional containers that run alongside an application container in a shared Podman pod. They share the network namespace (localhost connectivity) while maintaining separate filesystems. Sidecar templates are embedded in the charly binary — any image can get any sidecar at deploy time without image rebuilds.
| Action | Command |
|--------|---------|
| List available sidecars | charly config --list-sidecars |
| Attach sidecar | charly config <image> --sidecar tailscale |
| Attach with env | charly config <image> --sidecar tailscale -e TS_HOSTNAME=my-app |
| Check sidecar status | charly cmd <image> --sidecar tailscale "tailscale status" |
Sidecar templates are compiled into the charly binary via go:embed (charly/sidecar.yml). At deploy time, charly config --sidecar <name> merges the template with per-machine overrides from charly.yml, then generates quadlet files.
charly binary (embedded templates) charly.yml (per-machine overrides)
↓ ↓
sidecar templates env overrides + secrets
↓ MergeSidecars() ↓
ResolveSidecars()
↓
pod + sidecar + app quadlets
When sidecars are attached, charly config generates 3 files instead of 1:
| File | Purpose |
|------|---------|
| charly-<image>.pod | Pod definition: Network=charly, ports (PodmanArgs=-p), --shm-size |
| charly-<image>-<sidecar>.container | Sidecar: image, env, caps, devices, secrets, volumes |
| charly-<image>.container | App: Pod=charly-<image>.pod, no ports/network (pod owns them) |
The pod stays on the "charly" bridge network (Network=charly in .pod file) for container-to-container connectivity. The Tailscale sidecar creates a tailscale0 tun interface for exit node routing. --exit-node-allow-lan-access adds a routing exception (throw 10.89.0.0/24) that keeps bridge traffic on the bridge.
charly bridge (container-to-container)
┌─────────────────────────────────────┐
│ charly-<image> pod (Network=charly) │
│ ┌───────────┐ ┌────────────────┐ │
│ │ tailscale │ │ app container │ │
│ │ sidecar │ │ :3000, :9222 │ │
│ │ tailscale0│ │ │ │
│ │ (tun) │ │ │ │
│ └─────┬─────┘ └────────────────┘ │
└────────┼────────────────────────────┘
│ outbound internet only
exit node
CLI -e KEY=VALUE flags are automatically routed: env vars matching a sidecar template's keys (all TS_* for tailscale) go to the sidecar's charly.yml env override. Other vars go to the app container.
Sidecars participate in the same cross-container env discovery pipeline as regular candies, with one critical caveat: routing is explicit, not implicit. A sidecar's env (e.g., the tailscale sidecar's TS_* vars) is not auto-injected into the app container, and vice versa — the app only sees what it explicitly opts in to via env_accept or env_require in its charly.yml.
This matters for two reasons:
Prevents env var leakage. Without opt-in filtering, every deployed service would see every other service's env_provide. The chrome candy doesn't want TS_* vars in its env; the tailscale sidecar doesn't want BROWSER_CDP_URL. The filtering model is the mechanism that enforces this boundary.
Enforces dependency contracts. When the app declares env_require and the provider (or sidecar) is actually deployed, the provide-resolution pipeline (provides.go) satisfies the requirement without the user manually setting -e flags. When the provider is not deployed and no default is set, charly config fails hard — deployment does not proceed with a broken env contract.
The tailscale sidecar declares its advertised state (e.g., TS_HOSTNAME, the sidecar's tailnet IP) via the sidecar template, not via env_provide. The app container receives only what it declares in env_accept. Most images don't need to look at tailscale state from inside the container — the sidecar handles all routing transparently — so the accepts set is usually empty.
If a future sidecar needs to forward auth tokens or service URLs into the app, the right pattern is:
env_provide: with {{.ContainerName}}-templated valuesenv_accept: [<var>] or env_require: [<var>]charly config resolves the provide at deploy time and writes it to charly.yml under provides:Missing env_accept on the consumer side silently drops the var. Missing env_require is a hard fail. See /charly-image:layer (env_require / env_accept) for the authoring side, /charly-core:charly-config (Provides Filtering) for the resolution pipeline, and provides.go in the charly source for the implementation.
--sidecar assignments and -e overrides are saved to charly.yml. Subsequent charly config calls re-read them:
box:
selkies-desktop:
sidecars:
tailscale:
env:
TS_HOSTNAME: selkies-desktop
TS_EXTRA_ARGS: "--exit-node=100.80.254.4 --exit-node-allow-lan-access"
The built-in tailscale sidecar template (validated against tailscale/tailscale containerboot source):
The sidecar is parameterized on a tailnet: field declaring the target
tailnet's MagicDNS suffix. Each tailnet's auth-key lives in .secrets
(GPG-encrypted env file, loaded by direnv) under a per-tailnet env var
name derived from the suffix.
Schema in the embedded sidecar.yml:
sidecar:
tailscale:
parameter:
tailnet: "" # required: deploy must supply via parameter.tailnet
secret:
- name: ts-authkey
env: TS_AUTHKEY # container var (what tailscale's binary reads)
env_from: "TS_AUTHKEY_{{.Parameter.tailnet | tailnetEnvSuffix}}" # host-side var (what charly reads from .secrets)
Deploy.yml shape:
deploy:
sway-browser-vnc/ecovoyage:
sidecar:
tailscale:
parameter:
tailnet: armadillo-quail.ts.net # picks which per-tailnet auth-key to use
env:
TS_HOSTNAME: ecovoyage-browser
TS_ACCEPT_DNS: "true"
Storage convention (.secrets): the tailnetEnvSuffix template
helper uppercases the MagicDNS suffix and replaces every non-alphanumeric
character with _. So:
| MagicDNS suffix | Resolved host env var |
|---|---|
| armadillo-quail.ts.net | TS_AUTHKEY_ARMADILLO_QUAIL_TS_NET |
| tail297eca.ts.net | TS_AUTHKEY_TAIL297ECA_TS_NET |
| acme-corp.example.com | TS_AUTHKEY_ACME_CORP_EXAMPLE_COM |
Operator workflow — add a new tailnet:
# 1. Generate an auth-key in the Tailscale admin console, signed in as
# the OWNING ACCOUNT for the target tailnet. Verify which account
# you're in by checking the top-right email. The MagicDNS suffix is
# visible at https://login.tailscale.com/admin/dns
#
# Set: reusable=yes, ephemeral=yes, preauthorized=yes,
# tags=tag:server (or your ACL), expiration=90d (preference).
# 2. Store under the per-tailnet env var name:
charly secrets gpg set TS_AUTHKEY_ARMADILLO_QUAIL_TS_NET tskey-auth-XXXXXXXXX
# 3. Wire `parameter.tailnet:` into charly.yml under sidecar.tailscale:
# (operator-edited; no auto-write)
# 4. Wipe stale state so the sidecar re-auths with the new key:
charly volume reset <image> tailscale-state [-i <instance>]
# 5. Apply:
charly stop <image> [-i <instance>]
charly config <image> [-i <instance>]
charly start <image> [-i <instance>]
# 6. Verify the right tailnet was joined:
charly cmd <image> --sidecar tailscale "tailscale status --json" [-i <instance>] \
| python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["MagicDNSSuffix"])'
# Expected: armadillo-quail.ts.net
Migrating a legacy single-TS_AUTHKEY config:
A legacy flat config used a single TS_AUTHKEY env var, which can't
distinguish multiple tailnets. charly migrate upgrades it to the per-tailnet
form:
charly migrate
# Prompts for the tailnet the existing TS_AUTHKEY belongs to (auto-detects
# from a running sidecar when present), renames the .secrets entry to the
# per-tailnet form, and warns about charly.yml entries that need
# parameter.tailnet set.
Use --tailnet armadillo-quail.ts.net to skip the prompt, or
--delete-legacy to remove the original TS_AUTHKEY entry after the
rename. Idempotent.
Any deploy with a tailscale sidecar that doesn't supply
parameter.tailnet: fails at charly config time with the message:
sidecar "tailscale": sidecar secret "ts-authkey" references parameter "tailnet" which is unset. Set
sidecars.<sidecar-name>.parameter.tailnet: <value>in charly.yml or runcharly migrate
| Env var | Default | Purpose |
|---------|---------|---------|
| TS_STATE_DIR | /var/lib/tailscale | Persistent state (auth, node identity) |
| TS_AUTH_ONCE | true | Skip re-auth when state exists |
| TS_USERSPACE | false | Kernel mode — required for exit node routing |
| TS_DEBUG_FIREWALL_MODE | nftables | Force nftables (iptables-legacy fails in rootless podman) |
| TS_ACCEPT_DNS | false | Prevents Tailscale from rewriting /etc/resolv.conf. Pod quadlet adds explicit --dns flags for container DNS + MagicDNS |
| TS_ENABLE_HEALTH_CHECK | true | /healthz endpoint |
| TS_LOCAL_ADDR_PORT | [::]:9002 | Health check listen address |
| TS_AUTHKEY | Via secret | Auth key (from credential store) |
| TS_HOSTNAME | Per-image | Tailscale device name (set via -e) |
| TS_EXTRA_ARGS | Per-image | Extra tailscale up flags (e.g., --exit-node=<ip>) |
NET_ADMIN (iptables/nftables, IP forwarding), SYS_MODULE (tun/tap kernel module)/dev/net/tun (TUN/TAP virtual network device)charly-<image>-tailscale-state at /var/lib/tailscale — node identity persists across restartscharly-<image>-tailscale-ts-authkey — provisioned as podman secret from TS_AUTHKEY env var (loaded from .secrets via charly secrets gpg env)# Store auth key for the sidecar's tailnet
charly secrets gpg set TS_AUTHKEY tskey-auth-xxxxxxxxxxxx
# Deploy with exit node
charly config selkies-desktop --sidecar tailscale \
-e TS_HOSTNAME=selkies-desktop \
-e "TS_EXTRA_ARGS=--exit-node=100.80.254.4 --exit-node-allow-lan-access"
charly start selkies-desktop
# First time only: exit node must be set via tailscale set
# (TS_EXTRA_ARGS only applies on first auth, not restarts)
charly cmd selkies-desktop --sidecar tailscale \
"tailscale set --exit-node=100.80.254.4 --exit-node-allow-lan-access"
# Verify: pod shows exit node's IP, not host's
charly cmd selkies-desktop "curl -s ifconfig.me"
Prerequisites:
tailscale set --advertise-exit-node on the device)100.80.254.4), not a public IPPersistence: tailscale set --exit-node persists in the state volume. Survives pod restarts without re-configuration.
The sidecar runs its own tailscaled daemon in the pod's network namespace with its own state volume. It is completely isolated from the host's Tailscale:
| Aspect | Host Tailscale | Sidecar Tailscale |
|--------|---------------|-------------------|
| State | /var/lib/tailscale | Named volume |
| Auth key | Host's | Sidecar's TS_AUTHKEY |
| Network NS | Host | Pod (shared with app) |
| Control server | Default | Configurable via TS_EXTRA_ARGS=--login-server=... |
The host's tunnel: tailscale (configured in charly.yml) is independent of the sidecar:
ExecStartPost=tailscale serve runs on the host, exposing pod ports on the host's tailnetChrome requires large /dev/shm. In pod mode, per-container ShmSize= is ignored (pod infra container owns /dev/shm). The pod quadlet propagates ShmSize via PodmanArgs=--shm-size=1g.
/charly-core:deploy — Quadlet generation, charly.yml, tunnel configuration (tunnel is charly.yml-only, not auto-inherited by instances)/charly-core:charly-config — --sidecar and --list-sidecars flags, Provides Filtering, resource caps, NO_PROXY auto-enrichment/charly-image:layer — env_accept / env_require authoring and the full provides filtering contract/charly-build:secrets — charly secrets gpg set TS_AUTHKEY for auth key storage/charly-selkies:selkies-labwc — Full deployment example with Tailscale exit node/charly-selkies:chrome — Proxy deployment pattern (Tailscale exit node + HTTP_PROXY) and NO_PROXY auto-enrichment/charly-automation:enc — Encrypted volumes in pod deploymentscharly/sidecar.go (types, merge, resolution), charly/sidecar.yml (embedded templates), charly/quadlet_pod.go (pod + sidecar quadlet generation).
tools
OpenCharly CLI (charly) binary installed into container/VM images for in-container use. Use when working with charly binary deployment inside containers, native D-Bus support, or the full charly toolchain (charly binary + virtualization + gocryptfs + socat).
development
Operator CachyOS workstation profile — a kind:local template + target:local deploy that installs the full dev stack (30 candies) onto a CachyOS host via ShellExecutor. Lives in the overthinkos/cachyos submodule. MUST be invoked before editing or applying the charly-cachyos workstation profile.
tools
Fedora box with the full charly toolchain using shared candies. Rootless-first — runs as uid=1000 with passwordless sudo (no root, no cap_add: ALL). Same candy list as charly-arch. Includes NVIDIA GPU runtime. MUST be invoked before building, deploying, configuring, or troubleshooting the charly-fedora box.
tools
Arch Linux box with the full charly toolchain. Rootless-first — runs as uid=1000 with passwordless sudo (no root, no cap_add: ALL). Composes /charly-coder:charly-mcp so the box is reachable as an MCP gateway on port 18765. NVIDIA GPU runtime composed in. MUST be invoked before building, deploying, configuring, or troubleshooting the charly-arch box.