coolify-cli
CLAUDE.md486 starsChanged 2 months agoMITView on GitHub
This is a CLI tool for interacting with the Coolify API, built with Go using the Cobra framework. The CLI allows users to manage Coolify instances (both cloud and self-hosted), servers, projects, resources, deployments, domains, and private keys. All commands in this CLI are wrappers around API endpoints defined in the OpenAPI specification. When adding new features or endpoints: 1. Check the OpenAPI spec for available endpoints and their request/response schemas 2. Ensure the CLI command structure follows the API…
- Installs packages
We split this CLAUDE.md into the rules it gives an agent
46 rules. About 0 are common, 25 are distinctive, and 21 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈25
Distinctive · estimate
This author's own choices. Worth a look.
21
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 16
All commands in this CLI are wrappers around API endpoints defined in the OpenAPI specification.
- Distinctive: line 17
Check the OpenAPI spec for available endpoints and their request/response schemas
- Distinctive: line 18
Ensure the CLI command structure follows the API resource hierarchy
- Very specific: line 49
All API calls use Fqdn + "/api/v1/" + url pattern with Bearer token authentication
- Very specific: line 129
Create new file in cmd/ (e.g., cmd/newfeature.go)
- Very specific: line 132
Call CheckDefaultThings(nil) to validate version and format
- Very specific: line 133
Use Fetch(), Post(), or Delete() helpers
- Very specific: line 139
If a command requires a specific Coolify API version, pass it to CheckDefaultThings():
- Very specific: line 146
Use ShowSensitive flag to control display of tokens/secrets
- Distinctive: line 152
When adding new commands or models:
- Very specific: line 153
Command Arguments: Always accept UUIDs as string arguments (e.g., <resource_uuid>), never integer IDs
- Very specific: line 158
Keep ID int field with json:"id" table:"-" (for API responses, hidden from users)
- Very specific: line 159
Always include UUID string field with json:"uuid" (visible to users)
- Very specific: line 211
Every namespace bridge coolify-<ns>-mesh is created with --disable-dns --label io.coolify.managed=true --label io.coolify.namespace=<ns> — …
- Distinctive: line 333
Reconstructed-only state: no local state file. Every run re-probes via SSH.
- Very specific: line 337
Atomic config writes: write to .conf.tmp, mv to .conf.
- Very specific: line 351
When extending coolify init, defer dynamic responsibilities to coold.
- Very specific: line 361
Use the SSH Runner interface for mocking — never open real SSH connections in unit tests.
- Distinctive: line 428
Every coold call is wrapped in a single SSH command that first discovers the host's own wg0 mgmt IP and then curls coold on localhost:
- Very specific: line 477
Destination-host ownership: every rule lives on exactly one host — the one whose /24 contains the destination IP.
- Very specific: line 495
After coolify init bootstrap --servers A,B --namespaces default,alpha ...
- Very specific: line 505
Add --coold-token <hex> only when every host was bootstrapped with the same token (CI fixtures, homogeneous test clusters).
- Distinctive: line 513
New features: Must have 80%+ coverage
- Distinctive: line 514
Bug fixes: Must include regression tests
- Distinctive: line 515
Refactoring: Must maintain or improve existing coverage
- Distinctive: line 528
Test command parsing and flag handling
- Distinctive: line 529
Test output formatting (table, json, pretty)
- Distinctive: line 530
Use mock API client to avoid real API calls
- Distinctive: line 531
Test error handling and validation
- Distinctive: line 544
Test error handling (4xx, 5xx status codes)
- Very specific: line 547
IMPORTANT: Use httptest.NewServer() for mock HTTP responses (NOT real APIs)
- Distinctive: line 548
All API tests must use local mock servers, never call real Coolify cloud or external APIs
- Distinctive: line 557
Test JSON marshaling/unmarshaling
- Distinctive: line 562
Test full command execution
- Distinctive: line 563
Test with real HTTP server (httptest)
- Distinctive: line 564
Test config file operations
- Distinctive: line 594
Table-driven tests: Use for testing multiple scenarios
- Very specific: line 596
Subtests: Use t.Run() for related test cases
- Very specific: line 597
Setup/Teardown: Use TestMain() for package-level setup
- Very specific: line 598
Parallel tests: Use t.Parallel() when tests are independent
- Distinctive: line 599
Mock dependencies: Never call real APIs in unit tests
- Distinctive: line 657
Every commit to main branch
- Very specific: line 668
Use net/http standard library (no external HTTP frameworks)
- Distinctive: line 670
Follow RESTful patterns for API interactions
- Distinctive: line 672
Use Go's concurrency features appropriately
- Distinctive: line 673
Write secure, efficient, and maintainable code
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
This is a CLI tool for interacting with the Coolify API, built with Go using the Cobra framework. The CLI allows users to manage Coolify instances (both cloud and self-hosted), servers, projects, resources, deployments, domains, and private keys.
API Specification
This CLI is a client for the Coolify API. The API specification is defined in the OpenAPI schema:
- Source: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
- Raw JSON: https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/v4.x/openapi.json
- Base Path:
/api/v1/ - Authentication: Bearer token (API tokens from Coolify dashboard at
/security/api-tokens)
All commands in this CLI are wrappers around API endpoints defined in the OpenAPI specification. When adding new features or endpoints:
- Check the OpenAPI spec for available endpoints and their request/response schemas
- Ensure the CLI command structure follows the API resource hierarchy
- Match the API's data types and validation rules
Architecture
Command Structure
The codebase follows Cobra's command pattern with a root command and subcommands:
- Entry point:
coolify/main.gocallscmd.Execute() - Root command:
cmd/root.go- contains core utilities (HTTP client, authentication, version checking, config management) - Subcommands: Each command is in its own file in
cmd/:context.go- manage Coolify context (add, remove, list, set default/token)servers.go- list and get server informationprojects.go- list projects with environments and applicationsresources.go- list resourcesdeploy.go- deploy resourcesdomains.go- manage domainsprivatekeys.go- manage SSH keysupdate.go- self-update CLIversion.go- show CLI version
Configuration Management
- Uses Viper for configuration management
- Config file location:
~/.config/coolify/config.json(via xdg package) - Config stores multiple instances with tokens, default instance selection
- Global flags available:
--token,--host,--format,--show-sensitive,--force,--debug
API Communication
Core API functions in cmd/root.go:
Fetch(url string)- GET requestsPost(url, input)- POST requestsDelete(url)- DELETE requests All API calls useFqdn + "/api/v1/" + urlpattern with Bearer token authentication
Version Management
- CLI version tracking with auto-update check (10 minute interval)
- API version checking and minimum version enforcement via
CheckMinimumVersion() - Self-update capability using
go-selfupdatelibrary
Output Formatting
Three output modes supported via --format flag:
table(default) - tabwriter formatted outputjson- compact JSONpretty- indented JSON
Development Commands
Build
go build -o coolify ./coolifyRun locally
go run ./coolify [command]Test a command
go run ./coolify context list
go run ./coolify servers list --debugInstall locally
go install ./coolifyRun tests
# Run all tests (tests are in internal/ directory)
go test ./internal/...
# Run with coverage
go test ./internal/... -cover
# Run with verbose output
go test ./internal/... -v
# Run specific package
go test ./internal/api/... -v
go test ./internal/service/... -v
# Run specific test
go test ./internal/api -run TestClient_Get_Success -vBefore committing
# 1. Run tests
go test ./internal/...
# 2. Check coverage
go test ./internal/... -cover
# 3. Run linter (if available)
golangci-lint run
# 4. Format code
go fmt ./...Release Process
- Uses GoReleaser for multi-platform builds (Linux, Darwin, Windows on amd64/arm64)
- Release workflow:
.github/workflows/release-cli.ymltriggers on GitHub releases - GoReleaser config:
.goreleaser.yml - Install script:
scripts/install.shdownloads from GitHub releases
Key Patterns
Adding a New Command
- Create new file in
cmd/(e.g.,cmd/newfeature.go) - Define command struct with cobra.Command
- Implement Run function with:
- Call
CheckDefaultThings(nil)to validate version and format - Use
Fetch(),Post(), orDelete()helpers - Handle JSON unmarshaling into typed structs
- Support all three output formats
- Call
- Register command in
init()function:rootCmd.AddCommand(yourCmd)
API Version Requirements
If a command requires a specific Coolify API version, pass it to CheckDefaultThings():
minimumVersion := "4.0.0"
CheckDefaultThings(&minimumVersion)Handling Sensitive Data
- Use
ShowSensitiveflag to control display of tokens/secrets - Default overlay:
SensitiveInformationOverlay = "********"
UUID vs ID Pattern
CRITICAL: Always use UUIDs for user-facing interactions, never internal database IDs.
When adding new commands or models:
- Command Arguments: Always accept UUIDs as string arguments (e.g.,
<resource_uuid>), never integer IDs - API Endpoints: Construct API paths using UUIDs (e.g.,
resources/{uuid}), not IDs - Service Layer: Methods should accept
uuid stringparameters, notid int - Table Output: Hide internal IDs from table output using
table:"-"struct tags - Model Fields:
- Keep
ID intfield withjson:"id" table:"-"(for API responses, hidden from users) - Always include
UUID stringfield withjson:"uuid"(visible to users)
- Keep
Example model:
type Resource struct {
ID int `json:"id" table:"-"` // Hidden from table output
UUID string `json:"uuid"` // Shown in table output
Name string `json:"name"`
// ... other fields
}Why UUIDs?
- UUIDs are stable across environments (dev, staging, prod)
- IDs are internal implementation details that can change
- UUIDs are more secure (don't expose database sequencing)
- Coolify API uses UUIDs as the primary resource identifier
coolify init — WireGuard mesh + Podman bootstrap (alpha, v5)
Not registered on the public CLI. The cmd/init, cmd/firewall, and mesh packages stay in the repo (tests still run them) but cmd/root.go does not add them, so coolify init / coolify firewall are unknown commands.
This subcommand is an outlier: it does NOT talk to the Coolify API. It SSHes into remote hosts and installs/configures WireGuard, Podman, the bridge network, and a firewall scaffold. It's the fleet-provisioning command tree consumed by the v5 control plane (coold), split into three intent-scoped subcommands — bootstrap, extend, upgrade — plus a read-only plan. Coolify's backend calls extend when the operator adds a server and upgrade when agent versions move; direct-CLI operators run bootstrap for the initial install.
What it does
- Establishes a full-mesh WireGuard overlay across N hosts.
- Each host gets a mgmt IP
/32from--wg-mgmt-pool(default100.64.0.0/16, RFC 6598 CGNAT) onwg0. - For every namespace (see Namespaces below; default: just
default), each host gets a container subnet/<container-prefix>carved from the shared--container-pool(default10.210.0.0/16, default prefix/24). Each namespace is owned by its own Podman bridge namedcoolify-<namespace>-mesh(default →coolify-default-mesh). - Installs Podman + enables
podman.socket+ creates every namespace bridge + installscoolify-mesh-fw.service(always; required for v5 runtime). - Downloads and installs coold + corrosion (v5 control-plane agents; always) from GitHub releases on each remote host. Release tag controlled by
--coold-version/--corrosion-version(defaultnightly). coold receives the full namespace list viaCOOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,...so it can bind DNS and track rules per namespace. - Installs default-deny firewall scaffold by default — host-global
COOLIFY-INTRA+ emptyCOOLIFY-ALLOWchains, with FORWARD jumps for every namespace subnet. Use--skip-default-denyto fall back to blanket-allow (mode A) for testing.
Architecture (why this layout)
The mgmt pool and container pool are separate so the Podman bridge can own the full container /24 without conflicting with wg0. Pattern adopted from uncloud (psviderski/uncloud).
WG config per host (e.g. host A with two namespaces default + alpha):
[Interface]
Address = 100.64.0.1/32 # mgmt IP, NOT in container pool
ListenPort = 51820
PrivateKey = <gen on host>
[Peer] # one per other host
PublicKey = <peer pubkey>
AllowedIPs = 100.64.0.2/32, 10.210.1.0/24, 10.220.1.0/24 # mgmt + every namespace subnet
Endpoint = <peer SSH ip>:51820Critical: AllowedIPs lists the peer's full per-namespace /24s so the kernel routes each namespace subnet via wg0. Namespace order is deterministic (sorted) so wg0.conf is stable across re-runs.
Every namespace bridge coolify-<ns>-mesh is created with --disable-dns --label io.coolify.managed=true --label io.coolify.namespace=<ns> — the bridge gateway :53 is reserved for coold's embedded cluster DNS (see CONTROL_PLANE.md §5). Pre-alpha networks with dns_enabled=true are detected on re-run and recreated.
Firewall service (coolify-mesh-fw.service) installed unconditionally and stays host-global:
- POSTROUTING
RETURNrule per namespace subnet prevents Podman MASQUERADE from rewriting container egress source onwg0. - Mode A (
--skip-default-deny): blanket FORWARD ACCEPT for every namespace subnet. - Mode B (default):
COOLIFY-INTRAchain (ESTABLISHED accept →COOLIFY-ALLOW→ DROP), FORWARD jumps for-s/-d <ns-subnet>per namespace. v5 control plane (coold) fillsCOOLIFY-ALLOW.
Cross-host vs intra-host firewall
- Cross-host default-deny WORKS — those packets cross interfaces (wg0 ↔ bridge) and traverse iptables FORWARD. Empirically verified.
- Intra-host (same bridge) is NOT enforced — Linux + netavark + Ubuntu 24.04 quirk: bridge L2 traffic bypasses iptables FORWARD even with
bridge-nf-call-iptables=1. v5 control plane handles intra-host isolation via per-app podman networks (--opt isolate=true), not iptables.
Subcommands
Three intent-scoped subcommands. Each runs the same probe → plan → filter → apply → verify pipeline; what differs is the filter applied to the action list. The filter lives in internal/wireguard/intent.go (ValidateIntent + filterByIntent). Suppressed actions surface on plan.Skipped so the preview shows operators what would have fired and why.
coolify init plan --servers IP1,IP2,IP3 --ssh-key KEY [--intent bootstrap|extend|upgrade]
coolify init bootstrap --servers IP1,IP2,IP3 --ssh-key KEY [--yes]
coolify init extend --servers IP1,IP2,IP3,IP4 --new-hosts IP4 --ssh-key KEY [--allow-replace]
coolify init upgrade --servers IP1,IP2,IP3 --ssh-key KEY --coold-version v1.7.0 [--allow-nightly]planis read-only: probes, reconstructs, shows what the selected intent would execute. Default intent isbootstrap(broadest preview).bootstrapis the first-time install — every applicable action on every host. Keeps the interactive alpha gate (unless--yes,COOLIFY_NON_INTERACTIVE=1, or non-TTY). 2-phase parallel: phase 1 = install + keygen + podman + socket + IP forward. Re-probe. Phase 2 = write WG config + enable/reload service + create podman networks + install firewall + install coold/corrosion (+ scheduler on--central+ builder on--builder-hosts).extendadds the hosts listed in--new-hosts(required subset of--servers) to an existing mesh. Brand-new hosts get the full first-time install. Existing hosts get only peer-refresh actions (WG config rewrite picks up the new peer's mgmt/32+ namespace/24s inAllowedIPs, corrosion peer list refreshed, firewall unit reinstalled only when the namespace list changed). Agent binaries are not re-downloaded on existing hosts. Destructive-replace actions (podman network recreate because ofdns_enabled=truedrift or a subnet/label mismatch) are blocked on existing hosts unless--allow-replaceis passed. The corrosion-schema wipe-DB branch is never unlocked — resolve schema drift withupgradeon a fresh schema.upgradebumps agent binaries across every host. Only binary-fetch actions (install-coold,install-corrosion,install-scheduler,install-builder) and their follow-up service restarts (install-coold-service,install-corrosion-service,install-scheduler-service) run. WG config, podman networks, firewall rules, and the corrosion schema stay untouched.nightlytags are rejected by default (they force a re-install every run); pin a version with--coold-version=v1.7.0etc. or pass--allow-nightly.
extend and upgrade skip the interactive alpha gate because they are the paths the Coolify backend calls in production. bootstrap keeps the gate for direct-CLI runs.
Flags (defined in cmd/init/flags.go)
Persistent (inherited by plan, bootstrap, extend, upgrade):
| Flag | Default | Purpose |
--servers | required | comma-separated SSH IPs (full list of every host in the mesh, including already-converged ones on extend/upgrade) |
--ssh-key | required | path to SSH private key |
--ssh-passphrase-prompt | false | prompt for key passphrase (also reads COOLIFY_SSH_PASSPHRASE env) |
--ssh-user | root | SSH user |
--ssh-port | 22 | SSH port |
--wg-mgmt-pool | 100.64.0.0/16 | mgmt IP pool, /32 per host on wg0 |
--container-pool | 10.210.0.0/16 | container pool, carved per host |
--container-prefix | 24 | per-host container subnet prefix |
--wg-interface | wg0 | WG iface name on remote |
--wg-listen-port | 51820 | WG UDP port |
--namespaces | default | comma-separated list of namespaces. Each creates its own coolify-<ns>-mesh bridge with its own per-host /24 carved from --container-pool |
--skip-default-deny | false | skip the default-deny firewall scaffold. Default installs COOLIFY-INTRA + empty COOLIFY-ALLOW chains for cross-host deny |
--coold-version | nightly | release tag to download for coold (e.g. nightly, v1.2.3). nightly always re-downloads on every run; pinned tags skip when the on-host version marker matches. Fetched from coollabsio/coold GitHub releases on the remote host. |
--corrosion-version | nightly | release tag to download for corrosion. Same drift semantics as --coold-version. Fetched from coollabsio/corrosion GitHub releases. |
--scheduler-version | nightly | release tag for scheduler (only fetched when --central is set). |
--corrosion-gossip-port | 8787 | corrosion SWIM gossip port (bound to wg0 mgmt IP) |
--corrosion-api-port | 8080 | corrosion HTTP API port (bound to 127.0.0.1) |
--central | "" | SSH address of the central VM (must be in --servers). When set, scheduler installs there and per-host JWTs are pushed to every peer. Empty = skip scheduler setup. |
--enable-builder | true | cluster-wide shorthand: enable the builder capability on every host (requires --central). Ignored when --builder-hosts is set. |
--builder-hosts | [] | explicit subset of --servers to enroll with the builder capability. Takes precedence over --enable-builder. |
--builder-capacity | 2 | concurrent builds per host (COOLD_BUILDER_CAPACITY) |
--builder-cpu-quota | 200% | systemd CPUQuota per build subprocess |
--builder-memory-max | 2G | systemd MemoryMax per build subprocess |
--builder-timeout-secs | 1800 | wall-clock cap per build |
--concurrency | 10 | parallel SSH connections |
--ssh-timeout | 30s | SSH connect timeout |
--yes, -y | false | skip alpha confirmation prompt (honored by bootstrap; extend and upgrade always skip it) |
Subcommand-local:
| Flag | Subcommand | Default | Purpose |
--intent | plan | bootstrap | preview filter: bootstrap (all actions), extend (treat --new-hosts as fresh, existing hosts peer-refresh only), upgrade (version bumps only) |
--new-hosts | extend | required | comma-separated subset of --servers that is brand-new this run. Only these hosts receive the full install; all other hosts get peer-refresh only. |
--allow-replace | extend | false | unlock destructive-replace actions on existing hosts (e.g. recreating a drifted podman bridge). Off by default — drifted existing hosts surface as skipped actions. |
--allow-nightly | upgrade | false | permit nightly as a version tag. Off by default because nightly re-installs every run instead of only when the pinned version changes. |
Namespaces
Namespaces are the tenancy unit the mesh carries. A namespace is:
- A podman bridge network on every host, named
coolify-<ns>-mesh(default →coolify-default-mesh), labelledio.coolify.managed=true+io.coolify.namespace=<ns>. - A per-host
/<container-prefix>subnet carved from the shared--container-pool. Allocation is deterministic across(namespace, host)pairs so re-runs reproduce the same layout. - A DNS view coold serves on that bridge's gateway: records take the shape
<container>.<namespace>.coolify.internal. Bare<container>.coolify.internalis deliberately NXDOMAIN — callers must fully qualify. - A firewall tenant: allow-rule cids hash the namespace in, so identical src/dst/proto/port tuples in different namespaces are distinct rules. iptables chains stay host-global (
COOLIFY-INTRA/COOLIFY-ALLOW) for alpha; namespace isolation comes from separate podman bridges + namespace-qualified allow rules.
Config knobs:
coolify init bootstrap --namespaces default,alpha,betaprovisions every namespace on every host in one pass. Re-runningbootstrap(or runningextendwith the new namespace in--namespaces) installs only the new per-namespace assets (bridge + FORWARD jumps + WGAllowedIPsrefresh + firewall unit reinstall because of unit-hash drift). Removing a namespace is not idempotent today — destroy/rebuild is the documented path for alpha.coolify firewall --namespace <ns>(defaultdefault) scopes allow/revoke/list/containers to one namespace.listandcontainersalso accept--all-namespacesfor cross-namespace observability.- coold receives the full namespace list via
COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,…(seeinternal/services/coold.go). DNS binds and rule storage derive from that.
Deliberately deferred (tracked in the active plan):
- Per-namespace iptables chains. Host-global keeps kernel state simple; revisit when a user asks for kernel-enforced per-namespace default-deny.
- Cross-namespace L2 bridging. Different namespaces = different podman bridges = no intra-host connectivity. Cross-namespace flows require explicit allow rules + dual-attach containers.
- Wildcard / DNS search domain. Start strict; loosen once real workloads push back.
Code layout
cmd/common/— flag structs shared betweeninitandfirewall.sshmesh.go—SSHMeshFlags+BindSSHMeshFlags,BuildSSHClient,ParseSSHTimeout,ResolvePassphrase,Validate.meshnet.go—MeshNetFlags(namespaces + container pool/prefix) +BindMeshNetMultiFlags(init-style: many namespaces) +BindMeshNetSingleFlags(firewall-style: one namespace) +PodmanNetworkFor(ns)+ValidateNamespaces/ValidateNamespace(DNS-label check).
cmd/init/— Cobra subcommands (init,init plan,init bootstrap,init extend,init upgrade).flags.go—InitFlagsstruct (embedscommon.SSHMeshFlags+common.MeshNetFlags) + bindings + SSH client builder. Carries subcommand-scoped knobs:NewHosts,AllowReplace,AllowNightly,Intent.desired.go—buildDesired(flags): flag →wireguard.DesiredMesh. One source of truth so every subcommand produces the same struct moduloIntent.plan.go—runPlan: validate,buildDesired,ValidateIntent, build SSH client, probe,BuildPlan, render actions + skipped rows.--intentflag selects the filter for preview.apply.go—runApply(ctx, cmd, flags, applyOptions): shared pipeline for all three executing subcommands.applyOptions{SkipAlphaGate, Header}differentiates them.bootstrap.go—NewBootstrapCommand: setsflags.Intent = "bootstrap", keeps alpha gate.extend.go—NewExtendCommand: binds--new-hosts+--allow-replace, validates subset, setsflags.Intent = "extend", skips alpha gate.upgrade.go—NewUpgradeCommand: binds--allow-nightly, setsflags.Intent = "upgrade", skips alpha gate.init.go— registers the four subcommands; package isinitcmd(notinit— Go reserved keyword).
internal/wireguard/— pure Go logic (no SSH, no I/O —apply.gois the SSH boundary).state.go—ServerState(withNamespaces map[string]*NamespaceServerState),MeshState,DesiredMesh(withIntent,NewHosts,AllowReplace,AllowNightly).Intentenum:IntentBootstrap(zero value),IntentExtend,IntentUpgrade.intent.go—ValidateIntent(pre-plan invariants: extend needsNewHosts ⊆ Hosts; upgrade rejects nightly unless opted-in),filterByIntent(mutatesplan.Actions+plan.Skipped),categorize(action →catSafeAlways/catPeerRefresh/catDestructiveReplace/catVersionBump/catWipeDB/catCorrosionSchemaFirstWrite).subnet.go—Allocate(per(namespace, host)pair:map[ns]map[host]*net.IPNet) +AllocateMgmtIPs(per-host /32) + conflict detection. Provably stable: adding host D never shifts A/B/C.config.go—RenderConfig+WriteConfigCommandforwg0.conf(Address /32, AllowedIPs = mgmt /32 + every peer namespace subnet, deterministic order).reconstruct.go—Probe(per-namespace podman network inspect + label read) +Reconstruct(parallel) +parseConfigFile.plan.go—BuildPlan(pure: desired - actual = actions, thenValidateIntent+filterByIntent).Plan.Skipped []SkippedActioncarries intent-filtered entries with reasons. Podman actions carry aNamespacefield; one create/recreate action per namespace per host.apply.go—ApplyMesh(2-phase fanout viainternal/ssh/fanout.go). Phase 2 loops over namespaces per host; firewall unit takes the union of every namespace subnet.firewall.go—coolify-mesh-fw.serviceunit generator (two-mode: blanket allow vs default-deny, one FORWARD/POSTROUTING pair per namespace subnet).
internal/ssh/— generic SSH runner + parallelForEachServer[T].test/fixtures/wg/wg0.conf— fixture for parser tests.
Key invariants
- Reconstructed-only state: no local state file. Every run re-probes via SSH. State lives on the hosts.
- Idempotent: re-running with no changes produces an empty plan. State drift triggers re-converge (e.g. flipping
--skip-default-denyreinstalls the firewall service; bumping--coold-versionre-fetches the binary). - Intent gates destruction:
extendon an existing host never re-downloads agents, never wipes the corrosion DB, and never recreates a drifted podman bridge without--allow-replace. Suppressed actions surface onplan.Skippedwith a reason.upgradenever touches WG / podman / firewall / schema. - Private key never leaves host: WG private key generated on remote via
wg genkey; config written using$PRIVKEY=$(cat /etc/wireguard/privatekey)shell expansion. - Atomic config writes: write to
.conf.tmp,mvto.conf. - Non-disruptive WG reload: service-restart uses
systemctl restart wg-quick@wg0 || wg syncconf wg0 <(wg-quick strip wg0)— the fallback updates peers in kernel without tearing the tunnel. - Stable subnet assignment: existing valid assignments are preserved across re-runs; adding a host never shifts existing
(namespace, host)/24s. Only invalid (out-of-pool, wrong prefix, duplicate, network/broadcast IP) trigger reassignment with a warning. - Firewall reinstall is content-hashed:
coolify-mesh-fw.serviceis only rewritten when its expected unit text differs from the on-host sha256, so noisy restarts don't happen on converged re-runs.
Future control plane (v5 / coold)
coolify init owns fleet provisioning: first-time bootstrap, adding hosts, and bumping agent versions — each via its own intent-scoped subcommand. Day-to-day container/firewall ops are the v5 control plane's job. See CONTROL_PLANE.md for the full spec, including:
- coold per-host agent (REST API on wg0, bind-mounts
/run/podman/podman.sock, NEVER exposes socket on TCP). - Service discovery via embedded DNS in coold + Corrosion-replicated sqlite (no env injection, no container restart on backend movement).
- Allow-rule persistence via coold's own DB +
iptables-restore --noflushornft -fbatch (NOT systemd dropins per rule — doesn't scale). - Cross-host allow rules go on the destination host (where DROP would otherwise fire).
When extending coolify init, defer dynamic responsibilities to coold. Bootstrap stays narrow: scaffold the mesh, install runtime, prep firewall chains. extend and upgrade stay narrower still: add peers and bump binaries, nothing else. coold owns everything that changes at runtime.
Testing init
Tests live in internal/wireguard/*_test.go and cmd/init/*_test.go:
go test ./internal/wireguard/... ./cmd/init/... -vUse the SSH Runner interface for mocking — never open real SSH connections in unit tests. internal/ssh/fanout.go is generic; reuse for any per-server fanout.
coolify firewall — cross-host allow-rule client (alpha, v5)
Not registered on the public CLI (same as coolify init — see note above).
This subcommand is the second outlier (alongside coolify init): it does NOT talk to the Coolify API. It is a thin REST client of the coold per-host agent installed by coolify init (coold install is unconditional as of v1.6.3). allow / revoke / list all go through coold's REST API (/api/v1/firewall/allow). containers stays SSH+podman because coold has no container surface yet. Transport is SSH-bounce: the laptop running the CLI is not a mesh peer, so it SSHes into the target host and the shell there runs curl "http://$(wg0-mgmt-ip):8443/api/v1/firewall/..." against coold on localhost.
coold owns all kernel-rule + persistence logic (iptables/nft backend detection, /etc/coolify/allow.rules snapshot, coolify-mesh-allow.service). The CLI never writes iptables or systemd units directly.
What it does
- Discovers containers on the selected namespace's
coolify-<ns>-meshbridge (defaultcoolify-default-mesh) across all listed hosts (SSH +podman ps).--all-namespacesfans out across every managed namespace. POST /api/v1/firewall/allow/DELETE /api/v1/firewall/allow/{id}/GET /api/v1/firewall/allowagainst coold on the host that owns the destination IP (perCONTROL_PLANE.md §3: rules go on dst host).- Per-host bearer tokens fetched on demand from
/etc/coolify/api-token(seeEnsureCooldAPITokenCommandininternal/services/coold.go— each host generates its own random 32-byte hex token at install time). - Idempotent at the coold level: POST of an identical tuple returns the existing id; DELETE of an unknown id returns 204.
Subcommands
coolify firewall containers [--namespace <ns>] [--all-namespaces] # discover containers on coolify-<ns>-mesh (SSH+podman)
coolify firewall list [--namespace <ns>] [--all-namespaces] # GET /allow on every host and merge
coolify firewall allow --namespace <ns> --from <ref> --to <ref> [--port N] [--proto tcp|udp] [--bidirectional]
coolify firewall revoke --namespace <ns> --from <ref> --to <ref> [--port N] [--proto tcp|udp] [--bidirectional]<ref> accepts: container name (unique across mesh), host:name, short 12-char podman ID, or raw IP.
Flags
Persistent (inherited from cmd/common/sshmesh.go — shared with coolify init):
| Flag | Default | Purpose |
--servers | required | comma-separated SSH IPs |
--ssh-key | required | SSH private key path |
--ssh-passphrase-prompt | false | prompt for passphrase (also COOLIFY_SSH_PASSPHRASE env) |
--ssh-user | root | SSH user |
--ssh-port | 22 | SSH port |
--concurrency | 10 | parallel SSH connections |
--ssh-timeout | 30s | SSH connect timeout |
Firewall-specific persistent:
| Flag | Default | Purpose |
--namespace | default | mesh namespace the command operates on. Derives podman network coolify-<ns>-mesh for container discovery and is sent to coold as part of every rule payload / list query |
--all-namespaces | false | applies to list + containers only — fans out across every namespace the mesh carries (allow / revoke still require a specific --namespace) |
--coold-port | 8443 | TCP port coold's REST API listens on (wg0 mgmt IP). Must match COOLD_API_BIND emitted by internal/services/coold.go |
--coold-token | "" | optional bearer-token override (also reads COOLIFY_COOLD_TOKEN env). When empty (the default), the CLI SSHes each host and reads /etc/coolify/api-token — tokens are per-host, not centrally shared |
Allow/revoke local:
| Flag | Default | Purpose |
--from | required | source container ref or raw IP |
--to | required | destination container ref or raw IP |
--port | 0 | dst port (0 = any) |
--proto | tcp | tcp, udp, or "" (any — requires --port=0) |
--bidirectional | false | also install reverse rule on src host (needed for server-initiated flows; conntrack ESTABLISHED handles client-initiated replies) |
Rule identity
cid = sha256(namespace|src|dst|proto|port)[:12]. Namespace defaults to "default" on the wire when empty so legacy coold peers keep working. coold computes the cid server-side on POST and returns it in the body; the CLI surfaces it as the user-facing rule ID in firewall list output and uses it for DELETE. Stable across calls: revoke --namespace … --from … --to … rebuilds the same cid and matches. Identical src/dst/proto/port tuples in different namespaces produce different cids and are managed independently.
SSH-bounce transport
Every coold call is wrapped in a single SSH command that first discovers the host's own wg0 mgmt IP and then curls coold on localhost:
# emitted for POST / DELETE (hard-fails if wg0 missing — no coold means nothing to apply to)
MGMT=$(ip -4 -o addr show wg0 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
test -n "$MGMT" || { echo "coold mgmt IP (wg0) not found on $(hostname)" >&2; exit 1; }
curl -fsS --max-time 10 \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-X POST -d '{"src":"...","dst":"...","proto":"tcp","port":80}' \
"http://$MGMT:8443/api/v1/firewall/allow"list uses the soft variant: missing wg0 emits [] and exits 0 so a partially-deployed mesh doesn't abort the whole fanout.
Per-host token resolution
cmd/firewall/helpers.go::tokenResolver hands out tokens per host with a sync.Mutex-guarded cache:
--coold-token(orCOOLIFY_COOLD_TOKENenv) set → closure returns the override for every host; no SSH fetch.- Otherwise → first access per host SSHes
cat /etc/coolify/api-token, caches the result for the rest of the run. Token-fetch failures surface as aServerResult.Erron the owning host (won't poison others).
The cache is scoped to one CLI invocation — no on-disk caching.
Persistence across reboots
coold owns this now. On every API mutate, coold regenerates /etc/coolify/allow.rules (flat iptables-save fragment) and the companion coolify-mesh-allow.service restores it on boot via iptables-restore --noflush. Pre-coold persistence scaffolding was removed from the CLI when it migrated to REST — same file format, different writer.
Code layout
cmd/common/sshmesh.go— shared SSH/mesh flag structSSHMeshFlags(+BindSSHMeshFlags,BuildSSHClient,ParseSSHTimeout,ResolvePassphrase,Validate).cmd/common/meshnet.go— shared namespace plumbing:MeshNetFlags(namespaces + container pool/prefix),BindMeshNetMultiFlags(init: many),BindMeshNetSingleFlags(firewall: one),PodmanNetworkFor(ns),ValidateNamespaces/ValidateNamespace.cmd/firewall/— Cobra layer.firewall.go—NewFirewallCommand()parent + subcommand registration.flags.go—FirewallFlagsembedscommon.SSHMeshFlags+Namespace+AllNamespaces+CooldToken+CooldPort+WGInterface.PodmanNetworkName()derives the bridge name fromNamespace.ResolveCooldToken()returns the override or""(meaning "fetch per host").allow.go—allowRevokeFlags,emitAllowRevoke(discover → resolve → build rule with namespace → coold POST/DELETE per rule, resolving token per host).list.go—emitListfans outCooldListviaCooldListAll, forwarding the namespace query param (or omitting it under--all-namespaces).containers.go—containerssubcommand (still SSH+podman). Without--all-namespaces: single bridge. With--all-namespaces: SSH per host forpodman network ls --filter label=io.coolify.managed=true, then per-namespace fanout.resolve.go—resolveEndpoint(ref, []Container)(name / host:name / short-id / raw IP).helpers.go—discoverAllViaPkg,discoverAcrossNamespaces,discoverNamespacesOnHosts,tokenResolver(per-host cached bearer-token closure).
internal/firewall/— REST client + discovery.coold_client.go—FetchCooldToken,CooldApply,CooldRevoke,CooldList(… , namespace),CooldListAll(… , namespace).buildCurlAllow/Revoke/List,shellSingleQuote,mgmtIPScript/mgmtIPScriptSoft.cooldRulePayloadcarriesnamespace(required on wire; empty normalized to"default").discover.go—Container(withNamespace),discoverScript,DiscoverContainers(… , namespace, network),DiscoverAll,DiscoverAllNamespaces(fan-out over anetworkFor(ns)mapper).rule.go—AllowRule(withNamespace),ComputeID(namespace, src, dst, proto, port).
internal/models/firewall.go— table/JSON row types (ContainerRow,AllowRuleRow) both now carry aNamespacecolumn.internal/services/coold.go—EnsureCooldAPITokenCommand(installer writes/etc/coolify/api-token, mode 0600),CooldServiceUnitemitsCOOLD_API_BIND=<mgmt-ip>:8443+COOLD_API_TOKEN_FILE=/etc/coolify/api-token+COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,….
Key invariants
- Destination-host ownership: every rule lives on exactly one host — the one whose
/24contains the destination IP.--bidirectionaladds the reverse rule on the src host. - coold is the only kernel writer: the CLI never runs
iptablesor touches/etc/coolify/allow.rulesdirectly. Everything flows through coold's REST API. - Per-host tokens by default: each coold generates its own random token at install.
--coold-tokenis an escape hatch for homogeneous test / CI environments, not the common path. - Bidirectional is opt-in: conntrack ESTABLISHED accept (installed by
coolify-mesh-fw.service) handles reply packets for client-initiated flows. Only set--bidirectionalfor protocols that actually open new connections in both directions. - Rule identity is hash, not UUID: coold computes it server-side so CLI and any future writer agree on the same id for the same tuple.
- Namespace is part of identity:
cid = sha256(namespace|src|dst|proto|port)[:12]. Same tuple in two namespaces = two distinct rules. Empty-string namespace normalizes to"default"on the wire so legacy coold peers keep working. - Transient token exposure on remote
/proc:curl -H "Authorization: Bearer $TOKEN"is visible in/proc/<curl-pid>/cmdlinefor the ~ms lifetime of the call, root-only. Acceptable for alpha; TLS + stdin-fed tokens are a follow-up.
Testing firewall
go test ./internal/firewall/... ./cmd/firewall/... ./cmd/common/... -vUses fakeCooldRunner / cmdFakeRunner pattern (substring → canned stdout map) — same as cmd/init/plan_test.go. All SSH calls mocked at the ssh.Runner boundary; no real SSH in unit tests. Token-fetch, mgmt-IP script, curl shape, JSON payload, and error propagation are all covered.
End-to-end flow (verified on real hosts)
After coolify init bootstrap --servers A,B --namespaces default,alpha ... ran (coold must be up):
- Baseline cross-host traffic DROPped by
COOLIFY-INTRAin every namespace. coolify firewall containers --servers A,B --ssh-key KEY --all-namespaces→ discovery table columned by namespace.coolify firewall allow --servers A,B --ssh-key KEY --namespace default --from client --to web --port 80→ CLI SSH-fetches each host's token, POSTs to coold (body includes"namespace":"default"), traffic flows in thedefaultnamespace only.- Same tuple with
--namespace alpha→ separate cid, separate rule; doesn't affectdefault. coolify firewall list --servers A,B --ssh-key KEY --all-namespaces→ merged rules across every namespace on every host with their coold-assignedcid:…IDs.coolify firewall revoke --namespace <ns> …→ coold DELETE, rule gone, traffic DROPped again.- Reboot →
coolify-mesh-allow.service(installed by coold) restores from/etc/coolify/allow.rules.
Add --coold-token <hex> only when every host was bootstrapped with the same token (CI fixtures, homogeneous test clusters).
Testing Requirements
CRITICAL: All code changes MUST include tests. This is non-negotiable.
Test Coverage Requirements
- Minimum coverage: 70% for all packages
- New features: Must have 80%+ coverage
- Bug fixes: Must include regression tests
- Refactoring: Must maintain or improve existing coverage
Testing Structure
test/
├── fixtures/ # Test data, mock API responses
├── mocks/ # Mock implementations of interfaces
└── integration/ # Integration tests with test serverTest Requirements by Package Type
1. Command Tests (cmd/*_test.go)
- Test command parsing and flag handling
- Test output formatting (table, json, pretty)
- Use mock API client to avoid real API calls
- Test error handling and validation
- Example:
func TestServersListCmd(t *testing.T) {
// Test with mock client
// Verify output format
// Test error cases
}2. API Client Tests (internal/api/*_test.go)
- Test request building
- Test response parsing
- Test error handling (4xx, 5xx status codes)
- Test retry logic
- Test timeout behavior
- IMPORTANT: Use
httptest.NewServer()for mock HTTP responses (NOT real APIs) - All API tests must use local mock servers, never call real Coolify cloud or external APIs
3. Service Tests (internal/service/*_test.go)
- Test business logic
- Mock API client
- Test complex workflows
- Test error propagation
4. Model Tests (internal/models/*_test.go)
- Test JSON marshaling/unmarshaling
- Test validation logic
- Test helper methods
5. Integration Tests (test/integration/*_test.go)
- Test full command execution
- Test with real HTTP server (httptest)
- Test config file operations
- Test version checking
- Can be run with
-shortflag to skip
Running Tests
# Run all tests (tests are in internal/ directory)
go test ./internal/...
# Run with coverage
go test ./internal/... -cover
# Generate coverage report
go test ./internal/... -coverprofile=coverage.out
go tool cover -html=coverage.out
# Run with verbose output
go test ./internal/... -v
# Run only unit tests (skip integration)
go test ./internal/... -short
# Run specific package
go test ./internal/api/... -v
go test ./internal/service/... -vTest Guidelines
- Table-driven tests: Use for testing multiple scenarios
- Test naming:
TestFunctionName_Scenario_ExpectedBehavior - Subtests: Use
t.Run()for related test cases - Setup/Teardown: Use
TestMain()for package-level setup - Parallel tests: Use
t.Parallel()when tests are independent - Mock dependencies: Never call real APIs in unit tests
- Test fixtures: Store mock API responses in
test/fixtures/
Example Test Structure
func TestServersList(t *testing.T) {
tests := []struct {
name string
response string
wantErr bool
wantCount int
}{
{
name: "successful list",
response: readFixture("servers_list.json"),
wantErr: false,
wantCount: 3,
},
{
name: "empty list",
response: "[]",
wantErr: false,
wantCount: 0,
},
{
name: "api error",
response: `{"error":"unauthorized"}`,
wantErr: true,
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test implementation
})
}
}When Adding a New Command
CHECKLIST (must complete ALL items):
- Create command implementation in
cmd/ - Create corresponding test file in
internal/service/*_test.goorinternal/api/*_test.go - Test all flags and arguments
- Test all output formats (table, json, pretty)
- Test error cases (missing args, API errors, invalid input)
- Add integration test if command has complex workflow
- Update README.md with command documentation
- Run
go test ./internal/...and ensure all tests pass - Verify coverage:
go test ./internal/... -cover
CI/CD Integration
Tests run automatically on:
- Every pull request
- Every commit to main branch
- Before releases
Pull requests will be blocked if:
- Any test fails
- Coverage drops below 70%
- New code has no tests
.cursorrules Context
The project follows Go 1.22+ idioms with standard library preference:
- Use
net/httpstandard library (no external HTTP frameworks) - Leverage Go 1.22 ServeMux features for any routing needs
- Follow RESTful patterns for API interactions
- Implement proper error handling with custom types when needed
- Use Go's concurrency features appropriately
- Write secure, efficient, and maintainable code
- ALWAYS write tests - see Testing Requirements section above
In this repository2 other files it gives its agents.
- llms-full.txtllms.txt
- llms.txtllms.txt
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

