ov-core/skills/deploy/SKILL.md
MUST be invoked before any work involving: `ov deploy add`/`ov deploy del` commands, quadlet generation, volume backing, tunnels (Tailscale/Cloudflare), `add_layers:` 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.
The repo is migrating deploy.yml to schema v4 per /home/atrawog/.claude/plans/can-you-have-a-recursive-axolotl.md. Key changes:
target: only — legacy name-prefix (vm:<name>, literal host) is deprecated. Schema-v3 target values: host | vm | pod | k8s (short, matches ov command verbs).DeploymentNode — vm: <entity> for target: vm, image: <name> for target: pod, cluster: <name> for target: k8s, inside: <deploy> for nested host-deploy.DeploymentNode.Disposable is the sole source of truth. VmSpec.Disposable is retained during transition and removed in Phase 5.deploy.yml: arch-vm (target: vm, disposable libvirt VM), arch-vm.arch-host (target: host, nested inside arch-vm — zero operator FS writes), sway-pod (target: pod, image: openclaw-sway-browser), k3s-pod (target: pod, image: fedora-ov + k3s-server layer). Together they exercise all 19 ov eval verbs.Legacy spellings container / kubernetes / vm:<name> still work; ov migrate deploy-schema-v3 (Phase 6) converts legacy files.
ov deploy is the parent verb for applying and tearing down deployments, plus managing deploy.yml overrides. The command family has two distinct surfaces:
ov deploy add <name> / ov deploy del <name>. Apply or reverse a deployment. Four targets are dispatched by the target: field (schema v4) or the <name> prefix (legacy):
target: host → HostDeployTarget on the local filesystem (or, with inside: <deploy>, via NestedExecutor into the referenced deployment). See /ov-advanced:local-deploy.target: vm (+ vm: <entity>) → VmDeployTarget inside a running VM via SSH. See "VM target" section below and /ov-dev:vm-deploy-target.target: pod (+ image: <image>) → PodDeployTarget (today's ContainerDeployTarget, renamed in Phase 4): overlay Containerfile + quadlet/podman.target: k8s (+ cluster: <name>) → Kustomize base/overlays tree. See /ov-advanced:kubernetes.ov deploy show/export/import/reset/path/status. Read and mutate ~/.config/ov/deploy.yml itself.The same DeployImageConfig shape feeds all four targets (container, host, vm, kubernetes) — authors describe what the workload needs (ports, volumes, env, security, tests); the generator per target decides how K8s / quadlet / host apply / VM over SSH realizes it. K8s-specific choices (storage class, ingress class, cert issuer, secret backend) live in a cluster profile file (~/.config/ov/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.
ov start / ov stop remain as ergonomic wrappers: ov start <image> is equivalent to ov deploy add <image> <image> with the container target; ov stop <name> is ov deploy del <name>. New scripts should prefer the explicit ov deploy add/ov deploy del forms, especially when using --add-layer overlays or the host target.
| Action | Command | Description |
|--------|---------|-------------|
| Apply container deploy | ov deploy add <name> <ref> | Compile layers + build overlay if add_layers: present + run via quadlet |
| Apply host deploy | ov deploy add host <ref> | Apply layers directly to local filesystem (see /ov-advanced:local-deploy) |
| Tear down deploy | ov deploy del <name> | Stop container + reverse ReverseOps (host) + ledger cleanup |
| Dry-run | ov deploy add <name> <ref> --dry-run [--format=json] | Print the InstallPlan without executing |
| Layer overlay | ov deploy add <name> <ref> --add-layer <ref> | Extra layer(s) applied on top; repeatable |
| Configure deployment | ov config <image> | Generate .container file + save deploy.yml |
| Configure instance | ov config <image> -i <instance> | Generate instance-specific quadlet + deploy entry |
| Configure volume backing | ov config <image> --bind name | Set volume as host bind mount |
| Provision data | ov config <image> --seed | Auto-provision data layers into bind mounts (default) |
| Deploy status | ov deploy status | Audit deploy.yml vs quadlet sync |
| Show overrides | ov deploy show [image] | Display deploy.yml contents |
| Show instance overrides | ov deploy show <image> -i <instance> | Display instance-specific overrides |
| Import config | ov deploy import <files> | Merge files into deploy.yml |
| Reset config | ov deploy reset [image] | Remove deploy.yml overrides |
| Reset instance config | ov deploy reset <image> -i <instance> | Remove instance overrides |
| Push to registry | ov image build --push | Multi-platform push |
For service lifecycle commands (start/stop/status/logs/update/remove), see /ov-core:service. For VM lifecycle (build/create/start/stop/ssh), see /ov-advanced:vm; for in-VM layer deploys via ov deploy add vm:<name>, see the "VM target" section below and /ov-dev:vm-deploy-target. For encrypted storage, see /ov-advanced:enc. For host-target semantics, see /ov-advanced:local-deploy. For Kubernetes targets, see /ov-advanced:kubernetes. For the Go IR that drives all four targets, see /ov-dev:install-plan.
add / delov deploy add <name> [<ref>]Applies a deployment. <name> selects the target:
host (literal) — apply layers to the local filesystem via HostDeployTarget. One host deploy per machine (singleton). See /ov-advanced:local-deploy.vm:<vm-name> — apply layers inside a running kind: vm entity via SSH (VmDeployTarget). <vm-name> must match an entry in vms.yml; the VM must already be created (ov vm create <vm-name>). See "VM target" section below.kubernetes — emit a Kustomize base/overlays tree. See /ov-advanced:kubernetes.my-dev, postgres-staging, etc.); each gets its own quadlet, container name, and deploy.yml entry.<ref> accepts four forms, auto-detected:
| Form | Example | Resolution |
|---|---|---|
| Local image name | fedora-coder | Looked up in current project's image.yml |
| Local layer name | pre-commit | Looked up in current project's layers/ directory |
| Local YAML path | ./custom.yml, /abs/path/layer.yml | File's top-level keys classify image vs layer |
| Remote repo | github.com/owner/repo[/images/<n>\|/layers/<n>][@ref] | Fetched via existing --repo cache |
Disambiguation: a ref containing /layers/ resolves to a layer; /images/ to an image. For local names, image.yml is checked before layers/; same-named entries in both are a hard error. The legacy @host/org/repo:version form (used by depends: and layers: in layer.yml) is also accepted.
When <ref> is omitted, the ref falls back to deploy.yml['deploys'][<name>]['image'] (or the deploy key itself if no explicit image is declared).
ov deploy add flagsUniversal:
--tag <calver> — override deploy.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 eval: post-deploy--add-layer <ref> — repeatable; extra layer(s) applied on top (all 4 ref forms)Host-target-specific (silently ignored on container 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 preflightov deploy 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 ov vm destroy separately). See /ov-advanced:local-deploy for the full 15-kind ReverseOp table.
ov deploy 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 /ov-dev:vm-deploy-target).
Prerequisite: the VM must exist before ov deploy add vm:... runs. ov deploy add vm:<name> does NOT auto-provision; a missing VM produces a clean error pointing at ov vm create:
ov vm create arch # provision VM first
ov deploy add vm:arch ripgrep # then apply layer in guest
ov deploy add vm:arch fedora-coder \
--add-layer team-extras \
--add-layer github.com/team/configs/layers/sshkeys
ov deploy del vm:arch # reverse all applied layers (VM stays up)
VmDeployState schema in deploy.ymlWhen ov deploy add vm:<name> completes, a vm_state sub-object lands in the deploy.yml entry:
images:
vm:arch:
target: vm # optional; inferred from the vm: prefix
vm_source: # persisted copy of VmSpec.Source for re-apply
kind: cloud_image
url: https://fastly.mirror.pkgbuild.com/...
base_user: arch
add_layers:
- ripgrep
- github.com/team/configs/layers/sshkeys
install_opts:
verify: true
vm_state:
instance_id: 7b3a8f42-... # stable UUID across rebuilds
ssh_key_path: ~/.local/share/ov/vm/ov-arch/id_ed25519
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 ov deploy del vm:<name> knows which SSH key + NVRAM to use.
target: vm explicit declaration vs vm: prefixTwo ways to mark a deploy entry as VM-targeted:
vm:<vm-name>. CLI dispatch reads the prefix and routes to runVM.target: vm. Useful when you want a non-prefixed deploy name (e.g., for readability in deploy.yml).Using both is redundant but harmless. Using target: vm on a non-vm:-prefixed deploy whose underlying VM doesn't exist errors at ov deploy add time.
add_layers: overlay semantics for VM targetsWhen ov deploy add vm:<name> <ref> runs with --add-layer, the extra layers are applied inside the guest alongside the primary ref. The compiler merges <ref> + add_layers: into a single topo-sorted InstallPlan; VmDeployTarget.Emit walks it over SSH. The guest-side ledger records both the base and overlay layers so ov deploy del vm:<name> reverses the full set.
This is the same merge semantics as HostDeployTarget — just with SSH-wrapped execution. See /ov-dev:install-plan for the compiler and /ov-dev:vm-deploy-target for the execution model.
install_opts: fieldsinstall_opts: in the deploy.yml entry mirrors the CLI flags on ov deploy add. For VM targets, the relevant ones are:
| Field | Effect |
|---|---|
| with_services | 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 eval: 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.
vms.yml declares kind:vm entity
→ ov vm build <name> (cloud_image: fetch+resize+seed ISO; bootc: install to-disk)
→ ov vm create <name> (libvirt domain + SMBIOS ssh key + passt portForward)
→ ov vm start <name> (boot)
→ ov deploy add vm:<name> <ref> (SSH → cloud-init wait → ov install → layer apply)
→ ov deploy del vm:<name> (SSH → ReverseOps; VM stays up)
→ ov vm destroy <name> (remove libvirt domain; --disk to also delete qcow2)
See /ov-vms:vms for vms.yml authoring, /ov-advanced:vm for the lifecycle commands, /ov-dev:vm-deploy-target for the Emit flow.
add_layers: overlay mechanismBoth container and host targets accept extra layers at deploy time via --add-layer <ref> (repeatable) or a deploy.yml['deploys'][<name>]['add_layers'] list. Semantics:
FROM <base-image> + the extra layers' 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. ov deploy del <name> removes the overlay unless --keep-image.add_layers:, topo-sorts the union, and compiles one InstallPlan covering the combined set. The ledger records which layers (base + overlay) were applied so teardown reverses everything.Ref forms for --add-layer are identical to the primary <ref> positional (local name / local path / remote / legacy @ form).
Deploy a local image as a container:
ov deploy add my-dev fedora-coder
# Uses deploy.yml['deploys']['my-dev'] for volumes/ports/env/tunnel.
ov deploy del my-dev
Deploy directly to the host:
ov deploy add host fedora-coder --with-services --yes
ov deploy del host --yes
Deploy from a remote repo:
ov deploy add my-coder github.com/overthinkos/overthink/images/fedora-coder@main
ov deploy add host github.com/team-acme/private-configs/layers/my-team-tools
Add overlay layers:
ov deploy add host fedora-coder \
--add-layer team-extras \
--add-layer github.com/team/configs/layers/sshkeys \
--add-layer ./private.yml \
--with-services
Dry-run to preview the plan:
ov deploy add host fedora-coder --dry-run --format=json
ov start/ov stop equivalence:
ov start fedora-coder # == ov deploy add fedora-coder fedora-coder (container target)
ov stop fedora-coder # == ov deploy del fedora-coder
User-level systemd services via podman quadlet. Generated by ov config.
Path: ~/.config/containers/systemd/ov-<image>.container (or ov-<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: ov-<image>.service. Container name: ov-<image>. Entrypoint: determined by build.yml init: section for the configured init system. Encrypted volumes are mounted via ExecStartPre=ov config mount in the quadlet, which creates transient ov-enc-<image>-<volume>.scope units for each encrypted volume. These scope units are independent of the container service — they survive stop/restart (see /ov-advanced:enc). With Secret Service backend: auto-starts after login (ExecStartPre waits for keyring unlock, TimeoutStartSec=0). With KeePass or no backend: requires ov 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: ov/security.go, ov/quadlet.go.
When engine.build=docker, ov config auto-detects if the image is missing from podman and transfers via docker save | podman load. ov update re-transfers if needed.
Source: ov/quadlet.go (generation), ov/commands.go (command structs).
Expose services outside the container host via tunnels. Tunnel config lives exclusively in deploy.yml — it is NOT in image.yml or OCI image labels. ov 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 deploy.yml. The canonical form is bare H:C (e.g. 8888:8888); ov config prepends 127.0.0.1: automatically when a tunnel is set. Since 2026-04-29 the IP-prefixed form 127.0.0.1:8888:8888 (and IPv6 [::1]:8888:8888) is also accepted — the canonical ParsePortMapping helper in ov/ports.go normalizes both shapes to a single-prefixed PublishPort= line, so neither form produces the doubled 127.0.0.1:127.0.0.1:8888:8888 quadlet that earlier versions emitted. Unparseable port strings are now logged loudly to stderr instead of being silently dropped (the silent-skip used to 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 ov-<image>
fqdn: "app.example.com"
ov config handles the full tunnel lifecycle automatically:
cloudflared tunnel create) if it doesn't exist~/.config/ov/tunnels/<name>.yml) with ingress rules--overwrite-dns (creates or updates CNAME to tunnel)ov-<image>-tunnel.service)Wants= to the container quadletov start then starts both the container and the tunnel service together.
Port protocols declared in layer.yml control the backend URL scheme used by tunnel commands. The protocol flows from layer → OCI label (org.overthinkos.port_protos) → 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.
ov image 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 /ov-build:layer for port protocol syntax in layer.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 org.overthinkos.port_protos image label so deploy-mode commands work without access to the original layer definitions. (Pre-refactor, ov shell @github.com/... could fetch a remote repo and build on the fly; that path was deleted. Remote refs now require ov image pull first — see /ov-build:pull.)
Critical: When deploying instances with ov config setup -i <name>, tunnel config is NOT auto-inherited from the base image's deploy.yml entry. Each instance must have its own tunnel: section in deploy.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 org.overthinkos.tunnel OCI label — tunnel is deploy.yml-only. When ov config setup creates a new instance, it writes ports/env/security to deploy.yml but does not copy tunnel from the base entry.
Workaround: After ov config setup -i <name>, manually edit ~/.config/ov/deploy.yml to add tunnel: {provider: tailscale, private: all} to the instance entry, then re-run ov 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: ov/tunnel.go (schemeTarget, tailscaleFlag, isTCPFamily, validTailscaleSchemes, validCloudflareSchemes), ov/validate.go (validateTunnel), ov/quadlet.go (systemd integration).
~/.config/ov/deploy.yml is the source of truth for per-machine deployment configuration (not checked into git). All deployment commands read from image labels + deploy.yml — no image.yml needed.
ov config automatically persists: workspace, ports, env (CLI -e), env_file, network, security (auto-detected devices), volume backing (--bind/--encrypt)ov deploy import merges pre-provisioned config (tunnel, volumes, DNS) from filesov remove cleans the entry (use --keep-deploy to preserve for re-config)Schema v4 renamed the top-level images: map to deploy: and replaced the per-entry bind_mounts: field with a structured volumes: list. yaml.Unmarshal silently drops unknown root keys, so a pre-cutover file with images: would parse to an empty DeployConfig.Deploy map and downstream commands would behave as if nothing was deployed — including the dangerous case where bind_mounts: [{encrypted: true}] entries become invisible to loadEncryptedVolumes and the encryption guarantee silently disappears. LoadDeployConfig (ov/deploy.go:hasLegacyImagesKey) detects the legacy root shape and fails loud with:
deploy.yml at <path>: legacy top-level `images:` field detected — run `ov migrate local-deploy` to convert; the field was renamed to `deploy:` in the 2026-04 unified-config cutover (encryption guarantees disappear silently otherwise)
ov status surfaces this as a non-fatal warning (graceful degradation falls back to image-label-driven display); the strictly-deploy.yml-driven verbs (ov deploy show, ov config status, ov start) hard-fail. Run ov migrate local-deploy to convert in place — it backs the original up to <file>.bak.<unix-ts> and rewrites to schema v4. See /ov-build:migrate "ov migrate local-deploy".
version: 4
deploy:
my-app:
target: pod
tunnel:
provider: cloudflare
port: 2283
dns: "app.example.com"
volumes:
- name: data
type: bind
host: "~/data/myapp"
- name: workspace
type: bind
host: /home/user/project
path: /workspace
- name: secrets
type: encrypted
ports:
- "2283:2283"
env:
- "LOG_LEVEL=debug"
env_file: "/home/user/project/.env"
security:
devices:
- /dev/dri/renderD128
network: ov
engine: podman
Allowed fields: workspace, version, status, info, tunnel, fqdn, acme_email, volumes, ports, env, env_file, security, network, engine, secrets, target, add_layers, install_opts (new fields described below).
ov deploy add surfaceThe ov deploy add/del refactor introduced three fields on every deploy.yml image entry. They're honored only when relevant to the deploy target.
target: — "" or "container" (default, existing pipeline) or "host" (local filesystem apply). The deploy name host typically also sets this explicitly for clarity. When target: host is set on a non-host deploy name, ov deploy add errors cleanly — container deploy names can't secretly target the host.
add_layers: — list of 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-layer flag (local name / local YAML path / remote github.com/.../layers/<n>[@ref]). See "add_layers: overlay mechanism" above for container vs host semantics.
install_opts: — host-target defaults that mirror the CLI flags on ov deploy add. CLI flags win on conflict; deploy.yml provides defaults so you don't have to repeat --with-services --allow-repo-changes on every invocation.
images:
host:
image: fedora-coder
target: host
add_layers:
- my-team-vimrc # local layer
- github.com/team-acme/configs/layers/sshkeys # remote layer
- ./private-overlay.yml # local file
install_opts:
with_services: true
allow_repo_changes: true
allow_root_tasks: false
skip_incompatible: false
verify: true
builder_image: fedora-builder:2026.04
env:
OVERTHINK_DEV: "true"
Fields ignored on container deploys: install_opts (host-only). Fields ignored on host deploys: volumes, ports, tunnel, sidecars, security's container-runtime bits.
Cgroup memory and CPU limits are stored in the security: block of deploy.yml and persist across ov config re-runs (a --memory-max flag applied once stays in effect until explicitly changed). The fields are:
images:
selkies-desktop:
security:
shm_size: "1g"
memory_max: "6g"
memory_high: "5g"
memory_swap_max: "2g"
cpus: "4.0"
Merge semantics (authoritative, from ov/security.go):
| Source | Merge rule |
|---|---|
| Layer → layer | Smallest value wins (tightest cap is the safer default) |
| Layers → image-level security: in image.yml | Image-level replaces the merged layer value |
| Image-level → deploy-level security: in deploy.yml | Deploy-level replaces the image-level value |
| CLI flag → deploy-level | CLI flag writes directly to deploy.yml (--memory-max=... on ov 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 /ov-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 ov/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 deploy.yml. Only the fields you pass on the CLI get overwritten; everything else is preserved from the current deploy.yml state.
Canonical consumer: the chrome layer. See /ov-selkies:chrome (Resource Caps & Circuit Breaker) for the pattern that pairs these caps with supervisord's chrome-crash-listener event listener to trigger container rebuild on FATAL state — the only way to release orphan memfd shmem across a Chrome crash loop. See /ov-foundation:supervisord (Event Listeners) for the event listener pattern in full and /ov-build:layer (Security Declaration) for the authoring side.
The provides: section holds all resolved env and MCP provides entries from deployed images. Managed automatically by ov config when images with env_provides or mcp_provides layers are deployed.
provides:
env:
- name: OLLAMA_HOST
value: http://ov-ollama:11434
source: ollama
- name: PGHOST
value: ov-postgresql
source: postgresql
mcp:
- name: jupyter
url: http://ov-jupyter:8888/mcp
transport: http
source: jupyter
images:
my-app: { ... }
provides.env: — resolved env_provides entries with {name, value, source} (self-excluded per consumer)provides.mcp: — resolved mcp_provides entries with {name, url, transport, source} (pod-aware, no self-exclusion)source tracks which image injected each entry — used for cleanup on ov removeov config remove / ov remove automatically cleans up entries from the removed imageov 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 /ov-build:layer for env_provides/mcp_provides field declarations and /ov-core:config for --update-all propagation.
Per-deployment secret source overrides. Secrets declared in image labels (from layer.yml) are provisioned as Podman secrets at ov config time. Deploy.yml can override where the value comes from:
secrets:
- 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 deploy.yml:
ov config my-app --bind workspace=~/project # Saves volume config to deploy.yml
ov remove my-app --keep-deploy # Quadlet removed, config preserved
ov config my-app # Picks up volumes from deploy.yml
ov deploy status
# openclaw-ollama-sway-browser deploy.yml: yes quadlet: yes (ok)
# old-service deploy.yml: yes quadlet: no (stale config)
# manual-service deploy.yml: no quadlet: yes (no overrides)
Deployment commands (ov config, start, status, logs, update, remove, seed, service) resolve all configuration from OCI image labels + deploy.yml — no image.yml dependency. This means you can deploy on any machine with just ov image pull + ov 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 ov image pull. See /ov-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>:
ov config selkies-desktop -i work -e TS_HOSTNAME=work -p 3001:3000
ov config selkies-desktop -i personal -p 3002:3000
ov start selkies-desktop -i work
ov start selkies-desktop -i personal
Deploy key convention: Base images use selkies-desktop as the deploy.yml key. Instances use selkies-desktop/work (slash-separated). Functions: deployKey() constructs keys, parseDeployKey() splits them back. Source: ov/deploy.go.
Container naming: ov-<image>-<instance> (e.g., ov-selkies-desktop-work). Quadlet file: ov-selkies-desktop-work.container.
Deploy.yml structure with instances:
images:
selkies-desktop:
ports: [3000:3000]
selkies-desktop/work:
ports: [3001:3000]
env: [TS_HOSTNAME=work]
selkies-desktop/personal:
ports: [3002:3000]
Instance lifecycle: All commands accept -i: ov start/stop/status/logs/remove <image> -i <instance>, ov deploy show/reset <image> -i <instance>. Removing an instance only cleans its deploy.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: ov config remove disables the systemd service but does NOT remove the deploy.yml entry. You MUST also run ov deploy reset <image> -i <instance> and delete the quadlet file. If you run ov config --update-all before cleaning deploy.yml, stale quadlet files will be re-created. See /ov-core: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 /ov-core:config for details.
Layers declare what persistent storage they need via volumes: in layer.yml. By default, all volumes are Docker/Podman named volumes. At ov config time, any volume's backing can be changed to a host bind mount or encrypted gocryptfs mount.
ov config# Default: all volumes as named volumes (no flags needed)
ov config immich
# Configure specific volumes as bind mounts
ov config immich --bind import --bind external
# Bind mount with explicit host path
ov config immich --bind library=/mnt/nas/photos
# Configure volume as encrypted (gocryptfs)
ov config immich --encrypt library
# Canonical syntax: --volume name:type[:path]
ov config immich -v library:bind:/mnt/nas -v import:bind -v cache:encrypted
# Fully automated via env vars (no prompts)
OV_VOLUMES_IMMICH="library:bind:/mnt/nas,import:bind" ov config immich --password auto
Volume backing choices are persisted in deploy.yml:
volumes:
- 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 ov-<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 layers was provisioned (set by ov config)data_source: string — image:tag that provided the data (updated by ov config and ov 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/ov/volumes/. Configurable: ov settings set volumes_path /mnt/nas/ov (env: OV_VOLUMES_PATH).
Unconfigured volumes remain named volumes — no deploy.yml entry needed.
ResolveVolumeBacking() in ov/deploy.go splits image volumes into named volumes and bind-backed mounts:
org.overthinkos.volumes)type=bind → host bind mount (explicit path or auto path)type=encrypted → gocryptfs FUSE mountpath: set, not in any layer) are also supportedov config automatically provisions data from data layers into bind-backed volumes (via --seed, default true). ov update merges new data non-destructively. See /ov-core:config and /ov-core:updateov shell/ov start: resolves volume backing, verifies bind dirs exist and encrypted volumes are mounted, generates -v flagsov config (quadlet): bind-backed volumes become Volume= lines with host paths. --userns=keep-id added when bind-backed volumes existov remove --purge: removes named volumesov image inspect --format bind_mounts: outputs deploy-configured volume backingSource: ov/deploy.go (DeployVolumeConfig, ResolveVolumeBacking), ov/enc.go (ResolvedBindMount).
For images with wayvnc (VNC on tcp:5900), set a VNC password after enabling:
ov config openclaw-sway-browser
ov eval vnc passwd openclaw-sway-browser --generate # auto-generates password, prints to stdout
Or pre-set via settings before deployment:
ov settings set vnc.password.openclaw-sway-browser mysecret
ov config openclaw-sway-browser
# After container starts, run passwd to configure server-side auth:
ov eval vnc passwd openclaw-sway-browser # uses stored password (no prompt)
See /ov-advanced:vnc for full VNC authentication documentation.
Some services (OpenClaw) bind only to loopback for security. The port_relay field in layer.yml creates a socat relay from the container's network interface to loopback, making the service accessible externally without weakening its security model.
# In layer.yml
ports:
- 18789
port_relay:
- 18789
Requires the socat layer as a dependency. The relay runs as a relay-<port> service in the configured init system. See /ov-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 /ov-selkies:chrome and /ov-advanced:cdp for details.
Global environment and MCP server injection for all deployed images. Stored in deploy.yml under provides:.
provides:
env:
- name: OLLAMA_HOST
value: http://ov-ollama:11434
source: ollama
mcp:
- name: jupyter
url: http://ov-jupyter:8888/mcp
transport: http
source: jupyter
provides.env: — resolved env_provides entries with {name, value, source}provides.mcp: — resolved mcp_provides entries with {name, url, transport, source}source field tracks which image contributed each entry (used for cleanup on ov remove)ov config time from layer env_provides: and mcp_provides: declarationsGlobalEnvForImage() in provides.go resolves both env and MCP provides for each consumer imagelocalhost, no self-exclusion)OV_MCP_SERVERS JSON env var with resolved MCP server entriesSee /ov-core:config for setup workflow and /ov-build:layer for declaration format.
When sidecars are attached via ov config --sidecar <name>, deployment generates a Podman pod instead of a standalone container. See /ov-advanced:sidecar for full sidecar documentation.
| File | Content |
|------|---------|
| ov-<image>.pod | Pod: Network=ov, PodmanArgs=-p (ports), --shm-size |
| ov-<image>-<sidecar>.container | Sidecar: image, env, caps, devices, secrets |
| ov-<image>.container | App: Pod=ov-<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_provides 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.
images:
selkies-desktop:
sidecars:
tailscale:
env:
TS_HOSTNAME: selkies-desktop
TS_EXTRA_ARGS: "--exit-node=100.80.254.4 --exit-node-allow-lan-access"
Deploy surface (new):
/ov-advanced:local-deploy — Host-target execution model: HostDeployTarget, ledger, gates, 15 ReverseOp kinds, sudo batching/ov-dev:install-plan — The InstallPlan IR shared by ov image build (OCITarget), container deploys (ContainerDeployTarget), and host deploys (HostDeployTarget)/ov-dev:local-infra — Supporting Go files for host deploys: hostdistro, ledger, builder_run, shell_profile, reverse_ops, service_render, deploy_refDeploy-adjacent commands:
/ov-build:pull — Prerequisite: fetch the image into local storage; handles remote refs (@github.com/...) and the ErrImageNotLocal recovery path/ov-advanced:sidecar — Sidecar containers, pod networking, Tailscale exit nodes, Environment Contract (provides filtering)/ov-core:service — Service lifecycle (start/stop/update/remove)/ov-core:start — Ergonomic alias for ov deploy add <image> <image> (container target)/ov-core:stop — Ergonomic alias for ov deploy del <name>/ov-core:update — Per-instance update pattern; equivalent to ov deploy add <name> --pull/ov-core:config — Resource cap flags (--memory-max/high/swap/cpus), provides filtering, env_requires enforcement, NO_PROXY auto-enrichment, --sidecar, -i instance support, MCP name disambiguation/ov-advanced:enc — Encrypted storage commands (ov config mount/unmount)/ov-advanced:vnc — VNC password setup for desktop containers/ov-advanced:vm — Virtual machine deployment (ov vm)/ov-build:build — Building images before deployment (+ the --no-cache intermediate scratch-stage caveat)/ov-build:mcp — verify the MCP endpoints declared by provides.mcp: entries are actually reachable (ov eval mcp ping <image>); note the port-publishing gotcha when a ports: override in deploy.yml predates a newly-added mcp-providing layer/ov-build:image — Image configuration, OCI label emission, labels.go:238 tunnel read-skip/ov-build:layer — Unified service: schema (use_packaged + structured custom), env_provides/env_requires/env_accepts field declarations, security resource caps/ov-build:eval — Local eval: in deploy.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:
/ov-selkies:chrome — Resource caps consumer + crash-loop circuit breaker/ov-foundation:supervisord — Event listener pattern triggered by the caps; ServiceSchemaDef that renders service: entries to supervisord INI/ov-foundation:postgresql — Canonical use_packaged: entry (packaged unit reuse)/ov-ollama:ollama, /ov-hermes:hermes — Custom service: entries/ov-selkies:selkies-desktop — Multi-instance proxy deployment, tunnel inheritance workaroundA deploy entry's key in deploy: lives in its own namespace. The same name MAY simultaneously be a layer, an image: entry, a pod: entry, a vm: entry, a k8s: entry, a local: entry — and the deploy entry's cross-reference fields (image:, vm:, local:, cluster:) are scoped to the matching kind, no fall-through. Concrete worked example: this repo's deploy.ov-cachyos references local.ov-cachyos via local: ov-cachyos — same name across two namespaces.
ResolveDeployRef (used by ov deploy add <name> <ref>): when a name exists as BOTH an image and a layer, image-first precedence wins for the primary <ref> positional. The --add-layer <ref> path goes through ResolveDeployRefAsLayer which is layer-first. The retired image+layer ambiguity error is gone — same-name image and layer is permitted.
Migration for the operator-specific qc → ov-cachyos rename: ov migrate ov-cachyos (idempotent). Residual deploy.qc / deploy.cachyos-dx keys raise a hard load-time error. The 2026-05-XX per-kind file split also renamed the schema kind itself: kind: deployment → kind: deploy; root-key deployment: → deploy:; migration ov migrate kind-files. See /ov-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 /ov-build:build, before /ov-core:service.
Previous step: /ov-build:build (build the image). Next step: /ov-core:service (start, status, logs).
/ov-build:eval 10 standards)Changes that touch this verb's output must reach a healthy deployment on a target explicitly marked disposable: true (see /ov-dev:disposable). Use ov update <name> to destroy + rebuild unattended on any disposable target. Never experiment on a non-disposable deploy — set up a disposable one first with ov deploy add <name> <ref> --disposable or mark a VM in vms.yml.
After committing the source-level fix, ov 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.
development
Claude Code multi-agent support in Overthink — sub-agents, dynamic workflows, and agent teams, and how each drives the existing `ov eval` disposable beds to test and verify. MUST be invoked before authoring or invoking an ov sub-agent / dynamic workflow / agent team, wiring agent-lifecycle hooks, or asking "which primitive should drive the R10 beds?".
tools
Mounts a virtiofs share tagged `workspace` at /workspace inside a VM guest via a systemd .mount unit. Use when a kind:vm entity shares a host directory into the guest and you need it auto-mounted (and re-mounted at every boot).
development
MUST be invoked before any work involving: the `kind: android` schema kind, a `target: android` deploy, the `apk:` layer package format (installing Android apps declaratively), AndroidDeployTarget, an in-pod emulator OR a remote/physical adb-endpoint device, or nested `pod → android` deployment. The first-class Android device + app surface that sits above `ov eval adb`/`appium`.
tools
Use when committing, branching, pushing, merging, tagging, creating PRs, or approving/merging PRs with gh — the feat/-branch, R10-gated, never-force-push landing workflow across the main repo + the plugins submodule + image/<distro> submodules. Covers sync-to-upstream, branch/worktree pruning, the fork+PR path for contributors without write access, and cross-repo @github landing order.