diff --git a/.gitignore b/.gitignore
index 403028761..b09e074f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,6 @@ CHANGELOG.md
/.workspaces
tests/Browser/Screenshots
tests/v4/Browser/Screenshots
+
+# Local generated Lima configs
+.dev/lima/*.generated.yaml
diff --git a/README.md b/README.md
index 378e75ac7..46204ea5e 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,25 @@ You can find the installation script source [here](./scripts/install.sh).
> Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation.
+## Local dev with coold VM
+
+For v5 development that needs a packaged `coold` endpoint VM with Corrosion, use:
+
+```bash
+scripts/dev.sh up
+```
+
+This starts two Lima endpoint VMs by default, configures a WireGuard mesh between them, starts the Docker stack in the background, mints dev host JWTs with `php artisan flux:dev --caps=coold,builder`, installs them into the VMs, starts each VM `coold` agent service with builder capacity, and follows Coolify + VM agent logs by default. Flux runs with the Coolify container, not inside the VM. Set `COOLIFY_COOLD_VM_COUNT=1` for a single endpoint, `COOLIFY_COOLD_VM_ENABLED=false` to skip the VMs, `COOLIFY_COOLD_VM_BUILDER_CAPACITY=0` to disable builder capability, or `COOLIFY_DEV_FOLLOW_LOGS=false` to leave `up` detached. Use `scripts/dev.sh down` to stop the stack and VM agent; set `COOLIFY_COOLD_VM_STOP_ON_DOWN=true` if you also want the VM stopped. The dev entrypoints are intentionally kept to `scripts/dev.sh` for the full stack and `scripts/coold-vm.sh` for VM-only operations.
+
+Useful Corrosion checks:
+
+```bash
+scripts/dev.sh corrosion check
+scripts/dev.sh corrosion containers
+scripts/dev.sh corrosion config
+scripts/dev.sh corrosion sql 'select count(*) from service_endpoints;'
+```
+
## Container roles and Flux
The Coolify image can run different process roles with `COOLIFY_CONTAINER_ROLE`. The value can be a single role or a comma-separated list. If `all` is present anywhere in the list, every role starts.
diff --git a/app/Console/Commands/FluxDev.php b/app/Console/Commands/FluxDev.php
new file mode 100644
index 000000000..2c6cfc84e
--- /dev/null
+++ b/app/Console/Commands/FluxDev.php
@@ -0,0 +1,75 @@
+environment(['local', 'development', 'testing']) && ! $this->option('force')) {
+ $this->error('This command is intended for development only. Use --force to override.');
+
+ return self::FAILURE;
+ }
+
+ $privateKeyPath = config('flux.jwt_private_key_path');
+
+ if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) {
+ $this->error("Flux JWT private key not found at {$privateKeyPath}.");
+
+ return self::FAILURE;
+ }
+
+ $hostId = (string) $this->argument('host_id');
+ $ttl = max(60, (int) $this->option('ttl'));
+ $now = time();
+ $caps = collect(explode(',', (string) $this->option('caps')))
+ ->map(fn (string $cap) => trim($cap))
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ if ($caps === []) {
+ $caps = ['coold'];
+ }
+
+ $token = JWT::encode([
+ 'sub' => $hostId,
+ 'aud' => 'coold',
+ 'caps' => $caps,
+ 'iat' => $now,
+ 'exp' => $now + $ttl,
+ ], File::get($privateKeyPath), 'ES256');
+
+ $output = $this->option('output');
+
+ if (is_string($output) && $output !== '') {
+ $outputPath = Str::startsWith($output, '/') ? $output : base_path($output);
+ File::ensureDirectoryExists(dirname($outputPath));
+ File::put($outputPath, $token.PHP_EOL);
+ chmod($outputPath, 0600);
+ $this->info("Host JWT written to {$outputPath}.");
+
+ return self::SUCCESS;
+ }
+
+ $this->line($token);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Http/Controllers/V5/HomeController.php b/app/Http/Controllers/V5/HomeController.php
index b8f9fa6db..116952153 100644
--- a/app/Http/Controllers/V5/HomeController.php
+++ b/app/Http/Controllers/V5/HomeController.php
@@ -21,6 +21,7 @@ class HomeController extends Controller
return Inertia::render('Home', [
'status' => 'v5-ready',
'flux' => $fluxHealth->check(),
+ 'cooldHosts' => $this->cooldHosts(),
'currentTeam' => $currentTeam instanceof Team ? [
'id' => $currentTeam->id,
'name' => $currentTeam->name,
@@ -41,4 +42,29 @@ class HomeController extends Controller
]),
]);
}
+
+ /**
+ * @return array, builderEnabled: bool, builderCapacity: int}>
+ */
+ private function cooldHosts(): array
+ {
+ $baseId = (string) config('coold.dev_host_id');
+ $count = max(0, (int) config('coold.dev_host_count'));
+ $builderCapacity = (int) config('coold.dev_builder_capacity');
+ $builderEnabled = $builderCapacity > 0;
+
+ if ($count === 0 || $baseId === '') {
+ return [];
+ }
+
+ return collect(range(1, $count))
+ ->map(fn (int $index) => [
+ 'id' => $index === 1 ? $baseId : (string) config("coold.dev_host_id_{$index}", "{$baseId}-{$index}"),
+ 'wireguardIp' => (string) config("coold.dev_wireguard_ip_{$index}") ?: null,
+ 'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'],
+ 'builderEnabled' => $builderEnabled,
+ 'builderCapacity' => $builderCapacity,
+ ])
+ ->all();
+ }
}
diff --git a/config/coold.php b/config/coold.php
new file mode 100644
index 000000000..20dac4445
--- /dev/null
+++ b/config/coold.php
@@ -0,0 +1,11 @@
+ (int) env('COOLIFY_COOLD_VM_COUNT', 2),
+ 'dev_host_id' => env('COOLIFY_COOLD_DEV_HOST_ID', 'coolify-coold-dev'),
+ 'dev_host_id_2' => env('COOLIFY_COOLD_LIMA_INSTANCE_2', env('COOLIFY_COOLD_DEV_HOST_ID', 'coolify-coold-dev').'-2'),
+ 'dev_wireguard_ip_1' => env('COOLIFY_COOLD_VM_WG_IP_1', '100.64.0.10'),
+ 'dev_wireguard_ip_2' => env('COOLIFY_COOLD_VM_WG_IP_2', '100.64.0.11'),
+ 'dev_builder_capacity' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2),
+ 'dev_builder_enabled' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2) > 0,
+];
diff --git a/config/flux.php b/config/flux.php
index e82914de0..dea770e47 100644
--- a/config/flux.php
+++ b/config/flux.php
@@ -2,5 +2,7 @@
return [
'unix_socket_path' => env('COOLIFY_FLUX_UNIX_SOCKET_PATH', '/run/coolify/flux.sock'),
+ 'jwt_private_key_path' => env('COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH', storage_path('app/flux/jwt.priv')),
+ 'jwt_public_key_path' => env('COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH', storage_path('app/flux/jwt.pub')),
'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0),
];
diff --git a/dev/lima/coold.yaml b/dev/lima/coold.yaml
new file mode 100644
index 000000000..d0354b215
--- /dev/null
+++ b/dev/lima/coold.yaml
@@ -0,0 +1,125 @@
+# Lima VM for testing Coolify v5 against a packaged coold endpoint.
+# Start with: scripts/coold-vm.sh up
+# Run endpoint: scripts/coold-vm.sh dev
+#
+# This file is copied into .dev/lima/coold.generated.yaml by the wrapper script,
+# with {{COOLIFY_REPO}}, {{COOLIFY_COOLD_VERSION}}, and {{COOLIFY_CORROSION_VERSION}} replaced before Lima sees it.
+
+vmType: "vz"
+arch: "default"
+cpus: 4
+memory: "8GiB"
+disk: "100GiB"
+containerd:
+ system: false
+ user: false
+
+images:
+ - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
+ arch: "x86_64"
+ - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img"
+ arch: "aarch64"
+
+mountType: "virtiofs"
+mounts:
+ - location: "{{COOLIFY_REPO}}"
+ mountPoint: "/workspace/coolify"
+ writable: true
+
+portForwards:
+ - guestPort: 5173
+ hostPort: 5173
+ hostIP: "127.0.0.1"
+ - guestPort: 3000
+ hostPort: 3000
+ hostIP: "127.0.0.1"
+provision:
+ - mode: system
+ script: |
+ #!/usr/bin/env bash
+ set -euxo pipefail
+ export DEBIAN_FRONTEND=noninteractive
+ echo "[coold-vm] Updating Ubuntu package index..."
+ apt-get update
+ echo "[coold-vm] Installing VM dependencies..."
+ apt-get install -y --no-install-recommends \
+ bash \
+ buildah \
+ ca-certificates \
+ clang \
+ curl \
+ git \
+ iproute2 \
+ iptables \
+ jq \
+ libssl-dev \
+ mold \
+ nftables \
+ openssl \
+ perl \
+ pkg-config \
+ podman \
+ protobuf-compiler \
+ sqlite3 \
+ sudo \
+ wireguard-tools
+ echo "[coold-vm] Enabling Podman socket..."
+ systemctl enable --now podman.socket
+ cat >/usr/local/bin/rtk <<'SH'
+ #!/usr/bin/env bash
+ exec "$@"
+ SH
+ chmod +x /usr/local/bin/rtk
+ mkdir -p /etc/coolify /var/lib/corrosion /run/corrosion /var/lib/coolify-dev /var/lib/coolify-builder/work
+ chmod 755 /etc/coolify
+ chmod 777 /var/lib/corrosion /run/corrosion
+
+ install_coolify_binary() {
+ name="$1"
+ version="{{COOLIFY_COOLD_VERSION}}"
+ arch_raw="$(uname -m)"
+ case "$arch_raw" in
+ x86_64) arch=amd64 ;;
+ aarch64) arch=arm64 ;;
+ *) echo "unsupported arch: $arch_raw" >&2; exit 1 ;;
+ esac
+
+ url="https://github.com/coollabsio/coold/releases/download/${version}/${name}-linux-${arch}.tar.gz"
+ echo "[coold-vm] Installing ${name} from ${url}..."
+ tmpdir="$(mktemp -d)"
+ trap 'rm -rf "$tmpdir"' RETURN
+ curl -fsSL --retry 3 --max-time 120 -o "$tmpdir/${name}.tar.gz" "$url"
+ tar -xzf "$tmpdir/${name}.tar.gz" -C "$tmpdir"
+ test -f "$tmpdir/$name"
+ install -m 0755 "$tmpdir/$name" "/usr/local/bin/${name}.tmp"
+ mv "/usr/local/bin/${name}.tmp" "/usr/local/bin/${name}"
+ echo "$version" > "/usr/local/bin/${name}.version"
+ }
+
+ install_corrosion_binary() {
+ version="{{COOLIFY_CORROSION_VERSION}}"
+ arch_raw="$(uname -m)"
+ case "$arch_raw" in
+ x86_64) arch=x86_64-unknown-linux-gnu ;;
+ aarch64) arch=aarch64-unknown-linux-gnu ;;
+ *) echo "unsupported arch: $arch_raw" >&2; exit 1 ;;
+ esac
+
+ url="https://github.com/superfly/corrosion/releases/download/${version}/corrosion-${arch}.tar.gz"
+ echo "[coold-vm] Installing corrosion from ${url}..."
+ tmpdir="$(mktemp -d)"
+ trap 'rm -rf "$tmpdir"' RETURN
+ curl -fsSL --retry 3 --max-time 180 -o "$tmpdir/corrosion.tar.gz" "$url"
+ tar -xzf "$tmpdir/corrosion.tar.gz" -C "$tmpdir"
+ test -f "$tmpdir/corrosion"
+ install -m 0755 "$tmpdir/corrosion" /usr/local/bin/corrosion.tmp
+ mv /usr/local/bin/corrosion.tmp /usr/local/bin/corrosion
+ echo "$version" > /usr/local/bin/corrosion.version
+ }
+
+ install_coolify_binary coold
+ install_coolify_binary builder
+ install_corrosion_binary
+ echo "[coold-vm] Endpoint binaries installed."
+
+ echo "[coold-vm] Provisioning complete."
diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml
index bbb483d7a..164b1b338 100644
--- a/docker-compose-maxio.dev.yml
+++ b/docker-compose-maxio.dev.yml
@@ -12,8 +12,8 @@ services:
- "${APP_PORT:-8000}:8080"
environment:
AUTORUN_ENABLED: false
- PUSHER_HOST: "${PUSHER_HOST}"
- PUSHER_PORT: "${PUSHER_PORT}"
+ PUSHER_HOST: "${PUSHER_HOST:-}"
+ PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index bad72a22d..ea095f17f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -8,14 +8,18 @@ services:
args:
- USER_ID=${USERID:-1000}
- GROUP_ID=${GROUPID:-1000}
+ - COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly}
ports:
- "${APP_PORT:-8000}:8080"
- "${FORWARD_FLUX_PORT:-6443}:6443"
environment:
AUTORUN_ENABLED: false
COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}"
- PUSHER_HOST: "${PUSHER_HOST}"
- PUSHER_PORT: "${PUSHER_PORT}"
+ COOLIFY_COOLD_VERSION: "${COOLIFY_COOLD_VERSION:-nightly}"
+ COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}"
+ COOLIFY_CORROSION_VERSION: "${COOLIFY_CORROSION_VERSION:-v1.0.0}"
+ PUSHER_HOST: "${PUSHER_HOST:-}"
+ PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
diff --git a/resources/js/v5/Pages/Home.jsx b/resources/js/v5/Pages/Home.jsx
index 0fa9a231b..4ed49d968 100644
--- a/resources/js/v5/Pages/Home.jsx
+++ b/resources/js/v5/Pages/Home.jsx
@@ -1,6 +1,6 @@
import { Head } from '@inertiajs/react';
-export default function Home({ status, currentTeam, teams, flux }) {
+export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
return (
<>
@@ -27,6 +27,24 @@ export default function Home({ status, currentTeam, teams, flux }) {
{flux.socket ?
Socket: {flux.socket}
: null}
+
+ coold host
+
+
+ {cooldHosts.map((host) => (
+ -
+ {host.id}
+ {host.wireguardIp ? ` (${host.wireguardIp})` : ''}:
+ {' '}
+ {host.capabilities.join(', ')}; builder{' '}
+ {host.builderEnabled
+ ? `enabled, capacity ${host.builderCapacity}`
+ : 'disabled'}
+
+ ))}
+
+
+
Current team
{currentTeam ? (
diff --git a/scripts/coold-vm.sh b/scripts/coold-vm.sh
new file mode 100755
index 000000000..627717181
--- /dev/null
+++ b/scripts/coold-vm.sh
@@ -0,0 +1,591 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+read_coolify_env() {
+ key="$1"
+ default_value="$2"
+ current_value="${!key:-}"
+
+ if [ -n "$current_value" ]; then
+ printf '%s\n' "$current_value"
+ return
+ fi
+
+ if [ -f "$ROOT/.env" ]; then
+ env_value="$(grep -E "^${key}=" "$ROOT/.env" 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
+ if [ -n "$env_value" ]; then
+ printf '%s\n' "$env_value"
+ return
+ fi
+ fi
+
+ printf '%s\n' "$default_value"
+}
+
+INSTANCE="$(read_coolify_env COOLIFY_COOLD_LIMA_INSTANCE coold-dev)"
+VERSION="$(read_coolify_env COOLIFY_COOLD_VERSION nightly)"
+CORROSION_VERSION="$(read_coolify_env COOLIFY_CORROSION_VERSION v1.0.0)"
+FLUX_URL="$(read_coolify_env COOLIFY_COOLD_VM_FLUX_URL http://host.lima.internal:6443)"
+BUILDER_CAPACITY="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)"
+WG_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_IP "")"
+WG_PEER_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_IP "")"
+WG_PEER_ENDPOINT="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_ENDPOINT "")"
+WG_PEER_PUBLIC_KEY="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_PUBLIC_KEY "")"
+CONTAINER_SUBNET="$(read_coolify_env COOLIFY_COOLD_VM_CONTAINER_SUBNET 10.210.0.0/24)"
+CONTAINER_GATEWAY="$(read_coolify_env COOLIFY_COOLD_VM_CONTAINER_GATEWAY 10.210.0.1)"
+BUILDER_ENABLED="true"
+if [ "$BUILDER_CAPACITY" = "0" ]; then
+ BUILDER_ENABLED="false"
+fi
+TEMPLATE="$ROOT/dev/lima/coold.yaml"
+GENERATED="$ROOT/.dev/lima/coold.generated.yaml"
+GUEST_COOLIFY_ROOT="/workspace/coolify"
+
+usage() {
+ cat <
+
+Commands:
+ up Create/start the Lima VM and install packaged coold endpoint binaries
+ dev Start packaged coold + Corrosion inside the VM
+ start-agent
+ Start production-like coold.service + corrosion.service inside the VM
+ stop-agent
+ Stop the VM coold.service + corrosion.service
+ logs-agent
+ Follow the VM coold.service + corrosion.service logs
+ install-host-jwt [token]
+ Install a Flux host JWT into the VM at /etc/coolify/host-jwt
+ shell Open a shell inside the VM
+ status Show Lima instance status
+ stop Stop the VM
+ delete Delete the VM and all VM-local runtime state
+
+Environment:
+ COOLIFY_COOLD_LIMA_INSTANCE Override Lima instance name (default: coold-dev)
+ COOLIFY_COOLD_VERSION coold release tag to install (default: nightly)
+ COOLIFY_CORROSION_VERSION corrosion release tag to install (default: v1.0.0)
+ COOLIFY_COOLD_VM_FLUX_URL Flux gRPC URL visible from the VM (default: http://host.lima.internal:6443)
+ COOLIFY_COOLD_VM_BUILDER_CAPACITY VM builder capacity to advertise (default: 2; set 0 to disable)
+ COOLIFY_COOLD_VM_WG_IP Optional WireGuard mgmt IP for this host
+ COOLIFY_COOLD_VM_CONTAINER_SUBNET Podman mesh subnet for this host
+ COOLIFY_COOLD_VM_CONTAINER_GATEWAY Podman mesh gateway for this host
+
+Guest mounts:
+ $ROOT -> $GUEST_COOLIFY_ROOT
+
+Installed from:
+ https://github.com/coollabsio/coold/releases/tag/$VERSION
+ https://github.com/superfly/corrosion/releases/tag/$CORROSION_VERSION
+USAGE
+}
+
+require_lima() {
+ command -v limactl >/dev/null 2>&1 || {
+ echo "limactl is required. Install Lima first: brew install lima" >&2
+ exit 1
+ }
+}
+
+instance_exists() {
+ limactl list 2>/dev/null | awk 'NR > 1 {print $1}' | grep -qx "$INSTANCE"
+}
+
+instance_running() {
+ limactl list 2>/dev/null | awk -v name="$INSTANCE" 'NR > 1 && $1 == name {print $2}' | grep -qx Running
+}
+
+lima_shell() {
+ (cd /tmp && limactl shell "$INSTANCE" -- "$@")
+}
+
+vm_primary_ip() {
+ lima_shell sh -lc "ip -4 route get 1.1.1.1 | awk '{print \$7; exit}'"
+}
+
+wireguard_public_key() {
+ lima_shell sudo sh -lc 'install -d -m 0700 /etc/wireguard; if [ ! -s /etc/wireguard/privatekey ]; then wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey; chmod 600 /etc/wireguard/privatekey; fi; cat /etc/wireguard/publickey'
+}
+
+setup_wireguard() {
+ local ip="${1:-$WG_IP}"
+ local peer_ip="${2:-$WG_PEER_IP}"
+ local peer_endpoint="${3:-$WG_PEER_ENDPOINT}"
+ local peer_public_key="${4:-$WG_PEER_PUBLIC_KEY}"
+ local listen_port="${5:-51820}"
+ local peer_port="${6:-51820}"
+ local peer_subnet="${7:-}"
+
+ if [ -z "$ip" ]; then
+ echo "ERROR: WireGuard IP is required." >&2
+ exit 1
+ fi
+
+ wireguard_public_key >/dev/null
+
+ if [ -n "$peer_ip" ] && [ -n "$peer_endpoint" ] && [ -n "$peer_public_key" ]; then
+ lima_shell sudo sh -lc "cat >/etc/wireguard/wg0.conf.tmp </dev/null 2>&1 || true; wg-quick up wg0"
+ else
+ lima_shell sudo sh -lc "cat >/etc/wireguard/wg0.conf.tmp </dev/null 2>&1 || true; wg-quick up wg0"
+ fi
+}
+
+install_host_jwt() {
+ token="${1:-}"
+
+ if [ -z "$token" ]; then
+ token="$(cat)"
+ fi
+
+ if [ -z "$token" ]; then
+ echo "ERROR: host JWT is empty." >&2
+ exit 1
+ fi
+
+ printf '%s\n' "$token" | lima_shell sudo sh -c 'install -d -m 0755 /etc/coolify && cat > /tmp/coolify-host-jwt && install -m 0600 /tmp/coolify-host-jwt /etc/coolify/host-jwt && rm -f /tmp/coolify-host-jwt'
+}
+
+
+stop_agent_processes() {
+ lima_shell sudo systemctl stop coold.service corrosion.service coold-dev-agent.service >/dev/null 2>&1 || true
+ lima_shell sudo pkill -x coold >/dev/null 2>&1 || true
+ lima_shell sudo pkill -x corrosion >/dev/null 2>&1 || true
+}
+
+ensure_podman_networks() {
+ local current_subnet
+ current_subnet="$(lima_shell sudo podman network inspect coolify-default-mesh --format '{{range .Subnets}}{{.Subnet}}{{end}}' 2>/dev/null || true)"
+
+ if [ -n "$current_subnet" ] && [ "$current_subnet" != "$CONTAINER_SUBNET" ]; then
+ lima_shell sudo podman network rm coolify-default-mesh >/dev/null
+ current_subnet=""
+ fi
+
+ if [ -z "$current_subnet" ]; then
+ lima_shell sudo podman network create --subnet "$CONTAINER_SUBNET" --gateway "$CONTAINER_GATEWAY" coolify-default-mesh >/dev/null
+ fi
+}
+
+write_runtime_config() {
+ local gossip_addr="127.0.0.1:8787"
+ local bootstrap=""
+
+ if [ -n "$WG_IP" ]; then
+ gossip_addr="$WG_IP:8787"
+ fi
+
+ if [ -n "$WG_PEER_IP" ]; then
+ bootstrap="\"$WG_PEER_IP:8787\""
+ fi
+
+ lima_shell sudo install -d -m 0755 /etc/corrosion/schemas /etc/coolify /run/coolify /var/lib/corrosion /var/run/corrosion /var/lib/coolify-dev /var/lib/coolify-builder/work
+
+ lima_shell sudo tee /etc/corrosion/schemas/coolify.sql >/dev/null <<'SQL'
+CREATE TABLE service_endpoints (
+ container_id TEXT NOT NULL DEFAULT '' PRIMARY KEY,
+ container_name TEXT NOT NULL DEFAULT '',
+ namespace TEXT NOT NULL DEFAULT '',
+ host_mgmt_ip TEXT NOT NULL DEFAULT '',
+ container_ip TEXT NOT NULL DEFAULT '',
+ state TEXT NOT NULL DEFAULT '',
+ health TEXT NOT NULL DEFAULT 'unknown',
+ updated_at INTEGER NOT NULL DEFAULT 0
+);
+SQL
+
+ lima_shell sudo tee /etc/corrosion/config.toml >/dev/null </dev/null || cat /usr/local/bin/corrosion.version)"
+echo "starting packaged coold endpoint with corrosion in local mode"
+
+cleanup() {
+ jobs -pr | xargs -r kill || true
+}
+trap cleanup EXIT INT TERM
+
+/usr/local/bin/corrosion agent --config /etc/corrosion/config.toml &
+
+COOLIFY_COOLD_HOST_MGMT_IP="${COOLIFY_COOLD_HOST_MGMT_IP:-127.0.0.1}" \
+COOLIFY_COOLD_PODMAN_SOCKET="${COOLIFY_COOLD_PODMAN_SOCKET:-/run/podman/podman.sock}" \
+COOLIFY_COOLD_CORROSION_URL="${COOLIFY_COOLD_CORROSION_URL:-http://127.0.0.1:8080}" \
+COOLIFY_COOLD_NAMESPACES="${COOLIFY_COOLD_NAMESPACES:-default:coolify-default-mesh:$CONTAINER_GATEWAY}" \
+COOLIFY_COOLD_DNS_ZONE="${COOLIFY_COOLD_DNS_ZONE:-coolify.internal}" \
+COOLIFY_COOLD_API_BIND="${COOLIFY_COOLD_API_BIND:-${WG_IP:-127.0.0.1}:8443}" \
+COOLIFY_COOLD_API_TOKEN_FILE="${COOLIFY_COOLD_API_TOKEN_FILE:-/etc/coolify/api-token}" \
+COOLIFY_COOLD_FLUX_URL="${COOLIFY_COOLD_FLUX_URL:-http://host.lima.internal:6443}" \
+COOLIFY_COOLD_HOST_JWT_PATH="${COOLIFY_COOLD_HOST_JWT_PATH:-/etc/coolify/host-jwt}" \
+COOLIFY_COOLD_BUILDER_ENABLED="${COOLIFY_COOLD_BUILDER_ENABLED:-true}" \
+COOLIFY_COOLD_BUILDER_CAPACITY="${COOLIFY_COOLD_BUILDER_CAPACITY:-2}" \
+COOLIFY_COOLD_BUILDER_BIN="${COOLIFY_COOLD_BUILDER_BIN:-/usr/local/bin/builder}" \
+COOLIFY_COOLD_BUILDER_WORK_DIR="${COOLIFY_COOLD_BUILDER_WORK_DIR:-/var/lib/coolify-builder/work}" \
+/usr/local/bin/coold &
+
+wait
+RUNNER
+}
+
+install_mesh_firewall() {
+ lima_shell sudo install -d -m 0755 /etc/coolify
+ lima_shell sudo touch /etc/coolify/allow.rules /etc/coolify/allow.nft
+
+ lima_shell sudo tee /etc/coolify/bridge-fw.nft >/dev/null </dev/null </dev/null || /usr/sbin/iptables -t nat -I POSTROUTING -s $CONTAINER_SUBNET -o wg0 -j RETURN"
+ExecStart=/bin/sh -c "/usr/sbin/iptables -D FORWARD -s $CONTAINER_SUBNET -j ACCEPT 2>/dev/null || true"
+ExecStart=/bin/sh -c "/usr/sbin/iptables -D FORWARD -d $CONTAINER_SUBNET -j ACCEPT 2>/dev/null || true"
+ExecStart=/bin/sh -c "/usr/sbin/iptables -N COOLIFY-ALLOW 2>/dev/null || true"
+ExecStart=/bin/sh -c "/usr/sbin/iptables -N COOLIFY-INTRA 2>/dev/null || true"
+ExecStart=/usr/sbin/iptables -F COOLIFY-ALLOW
+ExecStart=/usr/sbin/iptables -F COOLIFY-INTRA
+ExecStart=/usr/sbin/iptables -A COOLIFY-INTRA -j COOLIFY-ALLOW
+ExecStart=/usr/sbin/iptables -A COOLIFY-INTRA -j DROP
+ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I FORWARD 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT"
+ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -d $CONTAINER_SUBNET -j COOLIFY-INTRA 2>/dev/null || /usr/sbin/iptables -A FORWARD -d $CONTAINER_SUBNET -j COOLIFY-INTRA"
+ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -s $CONTAINER_SUBNET -j COOLIFY-INTRA 2>/dev/null || /usr/sbin/iptables -A FORWARD -s $CONTAINER_SUBNET -j COOLIFY-INTRA"
+ExecStart=/bin/sh -c "nft delete table bridge coolify_bridge 2>/dev/null || true"
+ExecStart=/bin/sh -c "nft -f /etc/coolify/bridge-fw.nft"
+ExecStart=/bin/sh -c "[ -s /etc/coolify/allow.nft ] && nft -f /etc/coolify/allow.nft || true"
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+ lima_shell sudo systemctl daemon-reload
+ lima_shell sudo systemctl enable --now coolify-mesh-fw.service
+ lima_shell sudo systemctl restart coolify-mesh-fw.service
+}
+
+start_agent() {
+ stop_agent_processes
+ write_runtime_config
+ ensure_podman_networks
+ install_mesh_firewall
+ lima_shell sudo sh -c 'if [ ! -s /etc/coolify/api-token ]; then openssl rand -hex 32 > /etc/coolify/api-token.tmp && chmod 600 /etc/coolify/api-token.tmp && mv /etc/coolify/api-token.tmp /etc/coolify/api-token; fi'
+
+ lima_shell sudo tee /etc/systemd/system/corrosion.service >/dev/null <<'UNIT'
+[Unit]
+Description=Corrosion local state store
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+ExecStart=/usr/local/bin/corrosion agent --config /etc/corrosion/config.toml
+Restart=on-failure
+RestartSec=2s
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+ lima_shell sudo tee /etc/systemd/system/coold.service >/dev/null < "$GENERATED"
+}
+
+start_vm() {
+ generate_yaml
+
+ if instance_running; then
+ return
+ fi
+
+ if instance_exists; then
+ limactl start --tty=false "$INSTANCE"
+ else
+ limactl start --tty=false --name="$INSTANCE" "$GENERATED"
+ fi
+}
+
+latest_lima_message() {
+ log_file="$HOME/.lima/$INSTANCE/ha.stderr.log"
+
+ if [ ! -f "$log_file" ]; then
+ echo "creating Lima instance directory"
+ return 0
+ fi
+
+ grep -E '"msg":"(Starting VZ|Waiting for|The essential requirement|Executing /mnt/lima|SSH Local Port|Port is available|Attempting|Downloaded|Using the existing instance|The instance)' "$log_file" | tail -n 1 | sed -E 's/^.*"msg":"(.*)","time":.*$/\1/' | sed 's/\\"/"/g' || true
+}
+
+wait_for_lima_start() {
+ start_vm &
+ start_pid=$!
+ elapsed=0
+
+ while kill -0 "$start_pid" 2>/dev/null; do
+ if instance_exists && lima_shell true >/dev/null 2>&1; then
+ status="$(lima_shell cloud-init status 2>/dev/null || true)"
+ printf '==> [%3ss] Lima start: guest SSH ready, cloud-init %s
+' "$elapsed" "${status:-unknown}"
+ lima_shell sudo sh -c 'test -f /var/log/cloud-init-output.log && tail -n 12 /var/log/cloud-init-output.log || true' 2>/dev/null | awk '{ print "[guest] " $0; fflush(); }' || true
+
+ if ! printf '%s' "$status" | grep -q running; then
+ kill "$start_pid" >/dev/null 2>&1 || true
+ break
+ fi
+ else
+ message="$(latest_lima_message)"
+ printf '==> [%3ss] Lima start: %s
+' "$elapsed" "${message:-booting}"
+ fi
+
+ sleep 5
+ elapsed=$((elapsed + 5))
+ done
+
+ wait "$start_pid" 2>/dev/null || true
+}
+
+wait_for_guest_provisioning() {
+ echo "==> Waiting for guest SSH..."
+ until instance_exists && lima_shell true >/dev/null 2>&1; do
+ message="$(latest_lima_message)"
+ printf '==> Waiting for guest SSH: %s
+' "${message:-booting}"
+ sleep 5
+ done
+
+ status="$(lima_shell cloud-init status 2>/dev/null || true)"
+
+ if printf '%s' "$status" | grep -q running; then
+ echo "==> Guest SSH is ready; streaming cloud-init output until provisioning completes..."
+ (
+ lima_shell sudo sh -c 'touch /var/log/cloud-init-output.log; tail -n 40 -F /var/log/cloud-init-output.log' 2>/dev/null | awk '{ print "[guest] " $0; fflush(); }'
+ ) &
+ tail_pid=$!
+
+ while true; do
+ status="$(lima_shell cloud-init status 2>/dev/null || true)"
+ printf '==> Guest cloud-init: %s
+' "${status:-unknown}"
+
+ if ! printf '%s' "$status" | grep -q running; then
+ break
+ fi
+
+ sleep 5
+ done
+
+ kill "$tail_pid" >/dev/null 2>&1 || true
+ wait "$tail_pid" 2>/dev/null || true
+ else
+ printf '==> Guest cloud-init: %s
+' "${status:-unknown}"
+ fi
+
+ echo "==> Final guest provisioning status:"
+ lima_shell bash -lc 'cloud-init status 2>/dev/null || true; if command -v coold >/dev/null; then coold --version; else echo "coold not installed yet"; fi; if command -v corrosion >/dev/null; then echo "corrosion installed"; else echo "corrosion not installed yet"; fi; if command -v builder >/dev/null; then echo "builder installed"; else echo "builder not installed yet"; fi; true' | awk '{ print "[guest] " $0; fflush(); }' || true
+
+ if ! lima_shell bash -lc 'command -v coold >/dev/null && command -v corrosion >/dev/null && command -v builder >/dev/null' >/dev/null 2>&1; then
+ echo "ERROR: VM provisioning finished but coold, corrosion, or builder is missing." >&2
+ echo "Check with: scripts/coold-vm.sh shell" >&2
+ exit 1
+ fi
+}
+
+up_with_logs() {
+ echo "==> Coolify coold VM: $INSTANCE"
+ echo "==> coold package tag: $VERSION"
+ echo "==> corrosion package tag: $CORROSION_VERSION"
+ echo "==> Lima config: $GENERATED"
+
+ wait_for_lima_start
+ wait_for_guest_provisioning
+
+ echo "==> VM is ready. Run: scripts/coold-vm.sh dev"
+}
+
+cmd="${1:-}"
+case "$cmd" in
+ up)
+ require_lima
+ up_with_logs
+ ;;
+ dev)
+ require_lima
+ start_vm >/dev/null
+ run_foreground
+ ;;
+ start-agent)
+ require_lima
+ start_vm >/dev/null
+ start_agent
+ ;;
+ stop-agent)
+ require_lima
+ if instance_running; then
+ stop_agent_processes
+ fi
+ ;;
+ logs-agent)
+ require_lima
+ start_vm >/dev/null
+ exec bash -lc "cd /tmp && limactl shell '$INSTANCE' -- sudo journalctl -u coold.service -u corrosion.service -f -n 100"
+ ;;
+ install-host-jwt)
+ require_lima
+ start_vm >/dev/null
+ install_host_jwt "${2:-}"
+ ;;
+ wg-public-key)
+ require_lima
+ start_vm >/dev/null
+ wireguard_public_key
+ ;;
+ vm-ip)
+ require_lima
+ start_vm >/dev/null
+ vm_primary_ip
+ ;;
+ setup-wireguard)
+ require_lima
+ start_vm >/dev/null
+ setup_wireguard "${2:-}" "${3:-}" "${4:-}" "${5:-}" "${6:-}" "${7:-}" "${8:-}"
+ ;;
+ shell)
+ require_lima
+ start_vm >/dev/null
+ exec bash -lc "cd /tmp && limactl shell '$INSTANCE' -- env TERM=xterm-256color SYSTEMD_PAGER=cat SYSTEMD_LESS=FRXMK bash -l"
+ ;;
+ status)
+ require_lima
+ if instance_exists; then
+ exec limactl list "$INSTANCE"
+ fi
+
+ echo "No Lima instance named '$INSTANCE' exists yet."
+ echo "Run: scripts/coold-vm.sh up"
+ ;;
+ stop)
+ require_lima
+ exec limactl stop "$INSTANCE"
+ ;;
+ delete|destroy)
+ require_lima
+ exec limactl delete --force --tty=false "$INSTANCE"
+ ;;
+ -h|--help|help|"")
+ usage
+ ;;
+ *)
+ echo "unknown command: $cmd" >&2
+ usage >&2
+ exit 1
+ ;;
+esac
diff --git a/scripts/dev.sh b/scripts/dev.sh
new file mode 100755
index 000000000..f361a0fac
--- /dev/null
+++ b/scripts/dev.sh
@@ -0,0 +1,680 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+read_coolify_env() {
+ key="$1"
+ default_value="$2"
+ current_value="${!key:-}"
+
+ if [ -n "$current_value" ]; then
+ printf '%s\n' "$current_value"
+ return
+ fi
+
+ if [ -f .env ]; then
+ env_value="$(grep -E "^${key}=" .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
+ if [ -n "$env_value" ]; then
+ printf '%s\n' "$env_value"
+ return
+ fi
+ fi
+
+ printf '%s\n' "$default_value"
+}
+
+coold_vm_count() {
+ local count
+ count="$(read_coolify_env COOLIFY_COOLD_VM_COUNT 2)"
+
+ if [ "$count" != "1" ] && [ "$count" != "2" ]; then
+ echo "ERROR: COOLIFY_COOLD_VM_COUNT supports 1 or 2 for now." >&2
+ exit 1
+ fi
+
+ printf '%s\n' "$count"
+}
+
+coold_vm_instance() {
+ local index="$1"
+ local base
+ base="$(read_coolify_env COOLIFY_COOLD_LIMA_INSTANCE coold-dev)"
+
+ if [ "$index" = "1" ]; then
+ printf '%s\n' "$base"
+ return
+ fi
+
+ read_coolify_env "COOLIFY_COOLD_LIMA_INSTANCE_${index}" "${base}-${index}"
+}
+
+coold_vm_wg_ip() {
+ local index="$1"
+ read_coolify_env "COOLIFY_COOLD_VM_WG_IP_${index}" "100.64.0.$((9 + index))"
+}
+
+coold_vm_wg_port() {
+ local index="$1"
+ read_coolify_env "COOLIFY_COOLD_VM_WG_PORT_${index}" "$((51820 + index))"
+}
+
+coold_vm_container_subnet() {
+ local index="$1"
+ read_coolify_env "COOLIFY_COOLD_VM_CONTAINER_SUBNET_${index}" "10.210.$((index - 1)).0/24"
+}
+
+coold_vm_container_gateway() {
+ local index="$1"
+ read_coolify_env "COOLIFY_COOLD_VM_CONTAINER_GATEWAY_${index}" "10.210.$((index - 1)).1"
+}
+
+coold_vm() {
+ local index="$1"
+ shift
+ COOLIFY_COOLD_LIMA_INSTANCE="$(coold_vm_instance "$index")" \
+ COOLIFY_COOLD_VM_WG_IP="$(coold_vm_wg_ip "$index")" \
+ COOLIFY_COOLD_VM_CONTAINER_SUBNET="$(coold_vm_container_subnet "$index")" \
+ COOLIFY_COOLD_VM_CONTAINER_GATEWAY="$(coold_vm_container_gateway "$index")" \
+ scripts/coold-vm.sh "$@"
+}
+
+mint_host_jwt_for_host() {
+ local host_id="$1"
+ local attempts=60
+ local output
+ local caps
+ local builder_capacity
+
+ builder_capacity="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)"
+ caps="coold"
+ if [ "$builder_capacity" != "0" ]; then
+ caps="coold,builder"
+ fi
+
+ for attempt in $(seq 1 "$attempts"); do
+ if output="$(spin exec -T coolify php artisan flux:dev "$host_id" --caps="$caps" 2>&1)"; then
+ printf '%s\n' "$output" | tail -n 1
+ return 0
+ fi
+
+ echo "==> Waiting for Flux dev JWT key for ${host_id} (${attempt}/${attempts})..." >&2
+ printf '%s\n' "$output" | tail -n 3 | sed 's/^/[coolify] /' >&2
+ sleep 2
+ done
+
+ echo "ERROR: Could not mint Flux dev host JWT for ${host_id}." >&2
+ return 1
+}
+
+setup_wireguard_mesh() {
+ local count="$1"
+
+ if [ "$count" -lt 2 ]; then
+ return
+ fi
+
+ echo "==> Configuring WireGuard mesh for ${count} coold VMs..."
+
+ local pub1 pub2 ip1 ip2 port1 port2 subnet1 subnet2
+ pub1="$(coold_vm 1 wg-public-key)"
+ pub2="$(coold_vm 2 wg-public-key)"
+ ip1="$(coold_vm_wg_ip 1)"
+ ip2="$(coold_vm_wg_ip 2)"
+ port1="$(coold_vm_wg_port 1)"
+ port2="$(coold_vm_wg_port 2)"
+ subnet1="$(coold_vm_container_subnet 1)"
+ subnet2="$(coold_vm_container_subnet 2)"
+
+ coold_vm 1 setup-wireguard "$ip1" "$ip2" "host.lima.internal" "$pub2" "$port1" "$port2" "$subnet2"
+ coold_vm 2 setup-wireguard "$ip2" "$ip1" "host.lima.internal" "$pub1" "$port2" "$port1" "$subnet1"
+}
+
+follow_logs() {
+ local coold_vm_enabled="$1"
+ local count
+ local vm_logs_pids=""
+ count="$(coold_vm_count)"
+
+ echo "==> Following dev logs. Press Ctrl-C to stop the dev environment."
+
+ cleanup_logs() {
+ for pid in $vm_logs_pids; do
+ kill "$pid" >/dev/null 2>&1 || true
+ done
+ }
+
+ stop_from_signal() {
+ trap - INT TERM EXIT
+ cleanup_logs
+ echo
+ echo "==> Ctrl-C received; stopping dev environment..."
+ down
+ exit 130
+ }
+
+ trap cleanup_logs EXIT
+ trap stop_from_signal INT TERM
+
+ if [ "$coold_vm_enabled" != "false" ]; then
+ for index in $(seq 1 "$count"); do
+ instance="$(coold_vm_instance "$index")"
+ coold_vm "$index" logs-agent | sed "s/^/[${instance}] /" &
+ vm_logs_pids="$vm_logs_pids $!"
+ done
+ fi
+
+ spin logs -f
+}
+
+up() {
+ local coold_vm_enabled
+ local follow_dev_logs
+ local count
+ coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)"
+ follow_dev_logs="$(read_coolify_env COOLIFY_DEV_FOLLOW_LOGS true)"
+ count="$(coold_vm_count)"
+
+ if [ "$coold_vm_enabled" != "false" ]; then
+ echo "==> Starting ${count} Coolify coold VM(s) before Spin..."
+ for index in $(seq 1 "$count"); do
+ coold_vm "$index" up
+ done
+ setup_wireguard_mesh "$count"
+ else
+ echo "==> COOLIFY_COOLD_VM_ENABLED=false; skipping coold VM."
+ fi
+
+ echo "==> Starting Coolify Docker stack with Spin..."
+ spin up -d "$@"
+
+ if [ "$coold_vm_enabled" != "false" ]; then
+ for index in $(seq 1 "$count"); do
+ host_id="$(coold_vm_instance "$index")"
+ echo "==> Minting Flux dev host JWT for ${host_id}..."
+ host_jwt="$(mint_host_jwt_for_host "$host_id")"
+ echo "==> Installing host JWT into ${host_id}..."
+ coold_vm "$index" install-host-jwt "$host_jwt"
+ done
+
+ for index in $(seq 1 "$count"); do
+ echo "==> Starting coold VM agent service on $(coold_vm_instance "$index")..."
+ if [ "$count" -ge 2 ]; then
+ peer_index=1
+ if [ "$index" = "1" ]; then
+ peer_index=2
+ fi
+ COOLIFY_COOLD_VM_WG_PEER_IP="$(coold_vm_wg_ip "$peer_index")" coold_vm "$index" start-agent
+ else
+ coold_vm "$index" start-agent
+ fi
+ done
+ fi
+
+ if [ "$follow_dev_logs" = "false" ]; then
+ echo "==> Dev environment is ready. Use 'spin logs -f' or 'scripts/coold-vm.sh logs-agent' to follow logs."
+ return
+ fi
+
+ follow_logs "$coold_vm_enabled"
+}
+
+down() {
+ local coold_vm_enabled
+ local stop_coold_vm
+ coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)"
+ stop_coold_vm="$(read_coolify_env COOLIFY_COOLD_VM_STOP_ON_DOWN false)"
+
+ if [ "$coold_vm_enabled" != "false" ]; then
+ for index in $(seq 1 "$(coold_vm_count)"); do
+ echo "==> Stopping coold VM agent service on $(coold_vm_instance "$index")..."
+ coold_vm "$index" stop-agent || true
+ done
+ fi
+
+ echo "==> Stopping Coolify Docker stack with Spin..."
+ spin down "$@"
+
+ if [ "$stop_coold_vm" = "true" ]; then
+ echo "==> Stopping Coolify coold VM..."
+ for index in $(seq 1 "$(coold_vm_count)"); do
+ coold_vm "$index" stop
+ done
+ fi
+}
+
+corrosion_for_each_vm() {
+ local label="$1"
+ shift
+ local count
+ local script
+ count="$(coold_vm_count)"
+ script="$(cat)"
+
+ for index in $(seq 1 "$count"); do
+ instance="$(coold_vm_instance "$index")"
+ echo "--- ${instance}: ${label} ---"
+ printf '%s\n' "$script" | COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell "$@"
+ done
+}
+
+corrosion_check() {
+ local count
+ count="$(coold_vm_count)"
+
+ for index in $(seq 1 "$count"); do
+ instance="$(coold_vm_instance "$index")"
+ peer_index=1
+ if [ "$index" = "1" ]; then
+ peer_index=2
+ fi
+ peer_ip="$(coold_vm_wg_ip "$peer_index")"
+
+ echo "--- ${instance}: check ---"
+ COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell </dev/null 2>&1 && echo active || echo unavailable
+printf 'peer ping (${peer_ip}): '
+ping -c 1 -W 2 ${peer_ip} >/dev/null 2>&1 && echo ok || echo failed
+printf 'gossip: '
+awk '/^\[gossip\]/{section=1; next} /^\[/{section=0} section && /^addr =|^bootstrap =/ {printf "%s ", \$0} END {print ""}' /etc/corrosion/config.toml
+printf 'registered containers: '
+sudo sqlite3 /var/lib/corrosion/corrosion.db 'select count(*) from service_endpoints;' 2>/dev/null || echo unavailable
+SH
+ done
+}
+
+corrosion_containers() {
+ corrosion_for_each_vm containers <<'SH'
+echo '[corrosion service_endpoints]'
+sudo sqlite3 -header -column /var/lib/corrosion/corrosion.db \
+ 'select container_id, container_name, namespace, host_mgmt_ip, container_ip, state, health, updated_at from service_endpoints order by updated_at desc;' 2>/dev/null \
+ || echo 'service_endpoints table unavailable'
+
+echo
+echo '[rootful podman]'
+sudo podman ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null \
+ || echo 'rootful podman unavailable'
+
+echo
+echo '[rootless podman]'
+podman ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null \
+ || echo 'rootless podman unavailable'
+SH
+}
+
+corrosion_config() {
+ corrosion_for_each_vm config <<'SH'
+sudo cat /etc/corrosion/config.toml
+SH
+}
+
+corrosion_logs() {
+ local index="${1:-1}"
+ coold_vm "$index" logs-agent
+}
+
+corrosion_sql() {
+ local query="$*"
+
+ if [ -z "$query" ]; then
+ echo "Usage: scripts/dev.sh corrosion sql " >&2
+ exit 1
+ fi
+
+ corrosion_for_each_vm sql <
+
+Commands:
+ check Show Corrosion service, WireGuard, gossip, and row-count state
+ containers List registered service_endpoints rows on each VM
+ config Print /etc/corrosion/config.toml from each VM
+ logs [n] Follow coold/corrosion logs for VM n (default: 1)
+ sql Run a read-only sqlite query against each Corrosion DB
+USAGE
+ ;;
+ *)
+ echo "unknown corrosion command: $command" >&2
+ echo "Run: scripts/dev.sh corrosion help" >&2
+ exit 1
+ ;;
+ esac
+}
+
+example_nginx_name() {
+ local index="$1"
+
+ if [ "$index" = "1" ]; then
+ printf '%s\n' coolify-example-nginx
+ return
+ fi
+
+ printf 'coolify-example-nginx-%s\n' "$index"
+}
+
+example_nginx_up() {
+ local count
+ count="$(coold_vm_count)"
+
+ for index in $(seq 1 "$count"); do
+ instance="$(coold_vm_instance "$index")"
+ name="$(example_nginx_name "$index")"
+ gateway="$(coold_vm_container_gateway "$index")"
+
+ echo "--- ${instance}: starting ${name} with coold DNS (${gateway}) ---"
+ COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell </dev/null 2>&1 || true
+sudo podman run -d \\
+ --name ${name} \\
+ --network coolify-default-mesh \\
+ --dns ${gateway} \\
+ --dns-search default.coolify.internal \\
+ docker.io/library/nginx:alpine
+SH
+ done
+}
+
+example_nginx_down() {
+ local count
+ count="$(coold_vm_count)"
+
+ for index in $(seq 1 "$count"); do
+ instance="$(coold_vm_instance "$index")"
+ name="$(example_nginx_name "$index")"
+
+ echo "--- ${instance}: removing ${name} ---"
+ COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell </dev/null 2>&1 || true
+SH
+ done
+}
+
+example_nginx_check_dns() {
+ COOLIFY_COOLD_LIMA_INSTANCE="$(coold_vm_instance 1)" scripts/coold-vm.sh shell <<'SH'
+set -e
+echo '--- resolv.conf ---'
+sudo podman exec coolify-example-nginx cat /etc/resolv.conf
+echo
+echo '--- coold DNS lookup through search domain ---'
+sudo podman exec coolify-example-nginx nslookup coolify-example-nginx-2
+echo
+echo '--- coold DNS lookup by full name ---'
+sudo podman exec coolify-example-nginx nslookup coolify-example-nginx-2.default.coolify.internal
+SH
+}
+
+example_nginx_help() {
+ cat <<'USAGE'
+Usage: scripts/dev.sh example-nginx
+
+Commands:
+ up Start one nginx container on each coold VM with coold DNS configured
+ down Remove the example nginx containers
+ check-dns Verify host 1 nginx can resolve host 2 nginx through coold DNS
+USAGE
+}
+
+example_nginx() {
+ local command="${1:-help}"
+ if [ $# -gt 0 ]; then
+ shift
+ fi
+
+ case "$command" in
+ up)
+ example_nginx_up
+ ;;
+ down)
+ example_nginx_down
+ ;;
+ check-dns)
+ example_nginx_check_dns
+ ;;
+ -h|--help|help)
+ example_nginx_help
+ ;;
+ *)
+ echo "unknown example-nginx command: $command" >&2
+ echo "Run: scripts/dev.sh example-nginx help" >&2
+ exit 1
+ ;;
+ esac
+}
+
+firewall_help() {
+ cat <<'USAGE'
+Usage: scripts/dev.sh firewall
+
+Commands:
+ allow [proto] [port] Allow traffic on every coold VM (proto/port optional)
+ revoke [id|src] [dst] [proto] [port]
+ Remove an allow rule from every coold VM
+ list List allow rules on every coold VM
+ reconcile Re-apply firewall snapshot on every coold VM
+
+Examples:
+ scripts/dev.sh firewall allow 10.210.0.2 10.210.1.2 tcp 80
+ scripts/dev.sh firewall revoke
+ scripts/dev.sh firewall revoke 10.210.0.2 10.210.1.2 tcp 80
+ scripts/dev.sh firewall revoke 3ba6e0c235a6
+ scripts/dev.sh firewall list
+USAGE
+}
+
+firewall_api_for_each_vm() {
+ local label="$1"
+ local method="$2"
+ local path="$3"
+ local body="${4:-}"
+ local count
+ count="$(coold_vm_count)"
+
+ for index in $(seq 1 "$count"); do
+ instance="$(coold_vm_instance "$index")"
+ api_ip="$(coold_vm_wg_ip "$index")"
+ echo "--- ${instance}: ${label} ---"
+ COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell <&2
+ exit 1
+ fi
+
+ if [ -n "$port" ] && [ -z "$proto" ]; then
+ echo "ERROR: port requires proto (tcp or udp)." >&2
+ exit 1
+ fi
+
+ body="$(printf '{"namespace":"default","src":"%s","dst":"%s"' "$src" "$dst")"
+ if [ -n "$proto" ]; then
+ body="${body}$(printf ',"proto":"%s"' "$proto")"
+ fi
+ if [ -n "$port" ]; then
+ body="${body}$(printf ',"port":%s' "$port")"
+ fi
+ body="${body}}"
+
+ firewall_api_for_each_vm "allow ${src} -> ${dst}" POST /api/v1/firewall/allow "$body"
+}
+
+firewall_rule_id() {
+ local src="$1"
+ local dst="$2"
+ local proto="${3:-}"
+ local port="${4:-0}"
+
+ if [ -z "$proto" ]; then
+ port=0
+ fi
+
+ printf 'default|%s|%s|%s|%s' "$src" "$dst" "$proto" "$port" \
+ | shasum -a 256 \
+ | awk '{print substr($1, 1, 12)}'
+}
+
+firewall_revoke() {
+ local id_or_src="${1:-}"
+ local dst="${2:-}"
+ local proto="${3:-}"
+ local port="${4:-}"
+ local id
+
+ if [ -z "$id_or_src" ]; then
+ echo "Current firewall allow rule IDs:"
+ firewall_list
+ echo
+ echo "Revoke one with: scripts/dev.sh firewall revoke "
+ return
+ fi
+
+ if [ -z "$dst" ]; then
+ id="$id_or_src"
+ else
+ id="$(firewall_rule_id "$id_or_src" "$dst" "$proto" "$port")"
+ fi
+
+ firewall_api_for_each_vm "revoke ${id}" DELETE "/api/v1/firewall/allow/${id}"
+}
+
+firewall_list() {
+ firewall_api_for_each_vm list GET '/api/v1/firewall/allow?namespace=default'
+}
+
+firewall_reconcile() {
+ firewall_api_for_each_vm reconcile POST /api/v1/firewall/reconcile
+}
+
+firewall() {
+ local command="${1:-help}"
+ if [ $# -gt 0 ]; then
+ shift
+ fi
+
+ case "$command" in
+ allow)
+ firewall_allow "$@"
+ ;;
+ revoke|remove|delete|deny)
+ firewall_revoke "$@"
+ ;;
+ list)
+ firewall_list
+ ;;
+ reconcile)
+ firewall_reconcile
+ ;;
+ -h|--help|help)
+ firewall_help
+ ;;
+ *)
+ echo "unknown firewall command: $command" >&2
+ echo "Run: scripts/dev.sh firewall help" >&2
+ exit 1
+ ;;
+ esac
+}
+
+usage() {
+ cat <<'USAGE'
+Usage: scripts/dev.sh [spin args]
+
+Commands:
+ up Start the coold VM, Spin stack, and dev coold agent
+ down Stop the dev coold agent and Spin stack
+ shell [n] Open a shell inside coold VM n (default: 1)
+ list Show Lima instances
+ corrosion Inspect Corrosion state, config, logs, and registered containers
+ firewall Manage dev coold firewall allow rules
+ example-nginx Start/check example nginx containers with coold DNS
+USAGE
+}
+
+cmd="${1:-}"
+if [ $# -gt 0 ]; then
+ shift
+fi
+
+case "$cmd" in
+ up)
+ up "$@"
+ ;;
+ down)
+ down "$@"
+ ;;
+ shell)
+ coold_vm "${1:-1}" shell
+ ;;
+ list)
+ limactl list
+ ;;
+ corrosion)
+ corrosion "$@"
+ ;;
+ firewall)
+ firewall "$@"
+ ;;
+ example-nginx)
+ example_nginx "$@"
+ ;;
+ -h|--help|help|"")
+ usage
+ ;;
+ *)
+ echo "unknown command: $cmd" >&2
+ usage >&2
+ exit 1
+ ;;
+esac
diff --git a/tests/Feature/DevEnvironmentExampleTest.php b/tests/Feature/DevEnvironmentExampleTest.php
new file mode 100644
index 000000000..d5f2320cd
--- /dev/null
+++ b/tests/Feature/DevEnvironmentExampleTest.php
@@ -0,0 +1,31 @@
+not->toContain($variable.'=');
+})->with([
+ 'coold package version default' => 'COOLIFY_COOLD_VERSION',
+ 'flux package version default' => 'COOLIFY_FLUX_VERSION',
+ 'corrosion package version default' => 'COOLIFY_CORROSION_VERSION',
+ 'coold VM count default' => 'COOLIFY_COOLD_VM_COUNT',
+ 'coold VM flux URL default' => 'COOLIFY_COOLD_VM_FLUX_URL',
+ 'coold VM WireGuard IP 1 default' => 'COOLIFY_COOLD_VM_WG_IP_1',
+ 'coold VM WireGuard IP 2 default' => 'COOLIFY_COOLD_VM_WG_IP_2',
+ 'coold VM WireGuard port 1 default' => 'COOLIFY_COOLD_VM_WG_PORT_1',
+ 'coold VM WireGuard port 2 default' => 'COOLIFY_COOLD_VM_WG_PORT_2',
+ 'coold VM builder capacity default' => 'COOLIFY_COOLD_VM_BUILDER_CAPACITY',
+ 'coold VM enabled default' => 'COOLIFY_COOLD_VM_ENABLED',
+ 'coold VM stop on down default' => 'COOLIFY_COOLD_VM_STOP_ON_DOWN',
+ 'dev follow logs default' => 'COOLIFY_DEV_FOLLOW_LOGS',
+]);
+
+it('defaults coold dev VM settings in Laravel config', function () {
+ expect(config('coold.dev_host_count'))->toBe(2)
+ ->and(config('coold.dev_host_id'))->toBe('coolify-coold-dev')
+ ->and(config('coold.dev_host_id_2'))->toBe('coolify-coold-dev-2')
+ ->and(config('coold.dev_wireguard_ip_1'))->toBe('100.64.0.10')
+ ->and(config('coold.dev_wireguard_ip_2'))->toBe('100.64.0.11')
+ ->and(config('coold.dev_builder_capacity'))->toBe(2)
+ ->and(config('coold.dev_builder_enabled'))->toBeTrue();
+});
diff --git a/tests/Feature/FluxDevCommandTest.php b/tests/Feature/FluxDevCommandTest.php
new file mode 100644
index 000000000..6c9a3c810
--- /dev/null
+++ b/tests/Feature/FluxDevCommandTest.php
@@ -0,0 +1,65 @@
+ 'coold-dev',
+ '--caps' => 'coold,builder',
+ '--ttl' => '600',
+ ]);
+
+ expect($exitCode)->toBe(0);
+
+ $token = trim(Artisan::output());
+ $claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
+
+ expect($claims->sub)->toBe('coold-dev')
+ ->and($claims->aud)->toBe('coold')
+ ->and($claims->caps)->toBe(['coold', 'builder'])
+ ->and($claims->exp)->toBeGreaterThan(time());
+});
+
+it('writes the host jwt to an output path with owner-only permissions', function () {
+ [$privateKeyPath] = createFluxJwtKeypair();
+ $outputPath = storage_path('framework/testing/host-jwt');
+
+ Config::set('flux.jwt_private_key_path', $privateKeyPath);
+
+ $exitCode = Artisan::call('flux:dev', [
+ 'host_id' => 'coold-dev',
+ '--output' => $outputPath,
+ ]);
+
+ expect($exitCode)->toBe(0);
+
+ expect($outputPath)->toBeFile()
+ ->and(substr(sprintf('%o', fileperms($outputPath)), -4))->toBe('0600');
+});
+
+/**
+ * @return array{0: string, 1: string}
+ */
+function createFluxJwtKeypair(): array
+{
+ $directory = storage_path('framework/testing/flux-keys-'.bin2hex(random_bytes(4)));
+ mkdir($directory, 0777, true);
+
+ $privateKeyPath = $directory.'/jwt.priv';
+ $publicKeyPath = $directory.'/jwt.pub';
+
+ exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $output, $exitCode);
+ expect($exitCode)->toBe(0);
+
+ exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $output, $exitCode);
+ expect($exitCode)->toBe(0);
+
+ return [$privateKeyPath, $publicKeyPath];
+}
diff --git a/tests/Feature/V5/HomeTest.php b/tests/Feature/V5/HomeTest.php
index c37b47496..d4b4b96dc 100644
--- a/tests/Feature/V5/HomeTest.php
+++ b/tests/Feature/V5/HomeTest.php
@@ -105,6 +105,12 @@ it('serves the v5 inertia shell', function () {
->assertSee('v5-ready', false)
->assertSee('Running')
->assertSee('Flux is running.')
+ ->assertSee('coolify-coold-dev')
+ ->assertSee('coolify-coold-dev-2')
+ ->assertSee('100.64.0.10')
+ ->assertSee('100.64.0.11')
+ ->assertSee('builder')
+ ->assertSee('builderCapacity')
->assertSee('V5 Shared Team')
->assertSee('Shared team details')
->assertSee('owner')