48 KiB
VolcanicCore — Plan 4: Container + Coolify Deploy
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Package the ASP.NET Core 9 API + React SPA monolith into a single Docker image, wire the Coolify docker-compose deployment (named /data volume, expose: [8080], SERVICE_FQDN_APP_8080), fix the two Plan-4 items in docs/superpowers/followups.md (forwarded-headers + Secure-cookie behind Traefik; boot-time re-seed on legacy plaintext admin.password_hash_source), and ship the app live at https://core.tes.gd via a fire-and-forget v4 API deploy per the global CLAUDE.md rule.
Architecture: Three-stage Dockerfile: node:20-alpine runs pnpm build (produces the SPA into /build/src/VolcanicCore.Api/wwwroot/ — Vite's outDir is ../src/VolcanicCore.Api/wwwroot relative to frontend/, so we mirror that path in the stage); mcr.microsoft.com/dotnet/sdk:9.0 runs dotnet publish (produces /out); mcr.microsoft.com/dotnet/aspnet:9.0 (Debian bookworm-slim) is the runtime — we copy the publish output and overlay the SPA into /app/wwwroot, EXPOSE 8080, ENTRYPOINT ["dotnet", "VolcanicCore.Api.dll"]. docker-compose.yml uses expose: [8080], a named volume mounted at /data, and SERVICE_FQDN_APP_8080 for the auto-substitution. Program.cs gains UseForwardedHeaders (XForwardedProto + XForwardedFor, cleared KnownNetworks/KnownProxies) before UseAuthentication so the cookie policy sees TLS behind Traefik. AdminBootstrap.Seed catches CryptographicException on the legacy plaintext row and treats it as "unset" so the next boot re-seeds cleanly. Coolify wiring uses deploy.json (non-secret config) + env vars (COOLIFY_URL, COOLIFY_KEY, GIT_URL, GIT_USER, GIT_KEY); create-or-patch the app, PATCH git_repository to repair the truncation bug, PATCH docker_compose_domains (array form) with https://core.tes.gd:8080, POST env vars, then POST /deploy?uuid=<app> fire-and-forget.
Tech Stack: Docker dockerfile:1.7, node:20-alpine, mcr.microsoft.com/dotnet/sdk:9.0, mcr.microsoft.com/dotnet/aspnet:9.0, pnpm 9 (via corepack, matching pnpm-lock.yaml v9.0), Coolify v4 REST API, Traefik (Coolify-managed), curl + jq for deploy scripts.
Spec: docs/superpowers/specs/2026-08-28-volcaniccore-workflow-engine-design.md (§1 Architecture, §8 Deployment).
Preceding plans: Plan 1 (foundation), Plan 2 (workflow engine), Plan 3 (React SPA — all green: 74/74 backend, 20/20 frontend, 1/1 Playwright).
Follow-ups this plan clears: both Plan-4 items in docs/superpowers/followups.md — the forwarded-headers/Secure-cookie item and the legacy-plaintext admin.password_hash_source re-seed item. Both get removed from followups.md at close-out.
Divergences from the spec:
- Spec §8 says "distroless" was a possibility for the runtime. This plan uses
mcr.microsoft.com/dotnet/aspnet:9.0(Debian bookworm-slim) instead. Rationale: distroless images ship withoutcurl, breaking the compose healthcheck the skill recommends, and IronPython's P/Invoke surface hasn't been vetted on distroless.aspnet:9.0is ~200MB but comes with libc / libssl and we canapt-get install curlfor the healthcheck. Call this out at close-out as an invariant if we ever want to revisit. - Spec §8 mentions
SERVICE_FQDN_APP(without the port suffix). Coolify v4 actually requiresSERVICE_FQDN_<SVC>_<PORT>for compose builds — this plan usesSERVICE_FQDN_APP_8080per thedeploying-to-coolify-via-apiskill. - No divergence on live URL: the design didn't fix a domain. The user picked
core.tes.gdat plan-authoring time. That gets baked intodeploy.jsonbefore create.
File structure at end of plan
Dockerfile # NEW — multi-stage: web build + api publish + runtime
docker-compose.yml # NEW — Coolify-flavoured, expose:8080 + /data volume
.dockerignore # NEW — keeps bin/obj/node_modules/dist out of the build context
deploy.json # NEW — non-secret Coolify config (gitignored)
.gitignore # + deploy.json entry
src/VolcanicCore.Api/
Program.cs # + Services.Configure<ForwardedHeadersOptions>, + app.UseForwardedHeaders() BEFORE UseAuthentication
Auth/AdminBootstrap.cs # + try/catch (CryptographicException) around repo.Get(PasswordSourceKey)
tests/VolcanicCore.Tests/
ForwardedHeadersTests.cs # NEW — X-Forwarded-Proto: https → Request.Scheme is https, Set-Cookie has ; secure
AdminBootstrapPlaintextReseedTests.cs # NEW — seed plaintext row directly → Seed() heals it and next Get() decodes correctly
docs/superpowers/plans/2026-08-31-plan-4-coolify-deploy.md # this file
docs/superpowers/followups.md # -- remove both Plan-4 items at close-out
CLAUDE.md # + live URL, + Docker invariants, + Plan-4 close-out note
Task 1: Fix forwarded-headers + Secure cookie behind Traefik (TDD)
Clears follow-up #1 from docs/superpowers/followups.md. Without this, the cookie sent to browsers is missing the Secure flag behind Coolify/Traefik TLS termination, so a modern browser silently declines to send it back on subsequent requests, breaking login.
Files:
-
Test:
tests/VolcanicCore.Tests/ForwardedHeadersTests.cs(new) -
Modify:
src/VolcanicCore.Api/Program.cs(addServices.Configure<ForwardedHeadersOptions>+app.UseForwardedHeaders()beforeapp.UseAuthentication()) -
Step 1: Write the failing test
Create tests/VolcanicCore.Tests/ForwardedHeadersTests.cs:
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace VolcanicCore.Tests;
public class ForwardedHeadersTests : IClassFixture<WebFactory>
{
private readonly WebFactory _f;
public ForwardedHeadersTests(WebFactory f) { _f = f; }
[Fact]
public async Task Login_with_XForwardedProto_https_returns_Secure_cookie()
{
// AllowAutoRedirect false so we can inspect the raw Set-Cookie header.
var client = _f.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
// Simulate Traefik terminating TLS: it forwards over plain HTTP with the
// scheme and client IP in headers.
client.DefaultRequestHeaders.Add("X-Forwarded-Proto", "https");
client.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.7");
var req = new HttpRequestMessage(HttpMethod.Post, "/api/session")
{
Content = JsonContent.Create(new { password = _f.AdminPassword }),
};
var res = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
// Cookie should be marked Secure because the middleware saw https.
var setCookies = res.Headers.GetValues("Set-Cookie").ToList();
var sid = setCookies.FirstOrDefault(c => c.StartsWith("volcaniccore.sid=", StringComparison.Ordinal));
Assert.NotNull(sid);
Assert.Contains("secure", sid!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Login_without_XForwardedProto_returns_cookie_without_Secure()
{
// Same request WITHOUT the forwarded header — Request.Scheme stays http,
// so CookieSecurePolicy.SameAsRequest omits the Secure flag. This proves
// the middleware is doing the work; without it the "https" test above
// would still pass because both requests would look like http.
var client = _f.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var req = new HttpRequestMessage(HttpMethod.Post, "/api/session")
{
Content = JsonContent.Create(new { password = _f.AdminPassword }),
};
var res = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var setCookies = res.Headers.GetValues("Set-Cookie").ToList();
var sid = setCookies.FirstOrDefault(c => c.StartsWith("volcaniccore.sid=", StringComparison.Ordinal));
Assert.NotNull(sid);
Assert.DoesNotContain("secure", sid!, StringComparison.OrdinalIgnoreCase);
}
}
- Step 2: Run test to verify it fails
dotnet test --filter "FullyQualifiedName~ForwardedHeadersTests" --nologo
Expected: The Login_with_XForwardedProto_https_returns_Secure_cookie test fails. Without UseForwardedHeaders, Request.Scheme is "http" regardless of the X-Forwarded-Proto header, so the cookie has no Secure flag. The Login_without_XForwardedProto… test should already pass (baseline).
- Step 3: Wire forwarded-headers in
Program.cs
Modify src/VolcanicCore.Api/Program.cs:
At the top of the file, add the using:
using Microsoft.AspNetCore.HttpOverrides;
In the service registration block (immediately after the AddAuthorization() call, near line 108), add:
// Trust Coolify's Traefik as the TLS-terminating proxy. Clearing the default
// KnownNetworks/KnownProxies is required because the container talks to Traefik
// over the compose network — that IP is not in the built-in localhost list.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
Then in the middleware pipeline (currently the block starting app.UseAuthentication(); around line 158), insert UseForwardedHeaders before UseAuthentication:
// Must run before UseAuthentication so cookie SecurePolicy.SameAsRequest sees https.
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
- Step 4: Run the test to verify it passes
dotnet test --filter "FullyQualifiedName~ForwardedHeadersTests" --nologo
Expected: both tests pass.
- Step 5: Run the full suite to confirm no regressions
dotnet test --nologo
Expected: 76 passed (74 previous + 2 new).
- Step 6: Commit
git add tests/VolcanicCore.Tests/ForwardedHeadersTests.cs src/VolcanicCore.Api/Program.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(api): honour X-Forwarded-Proto so Secure cookies flip on behind Traefik"
Task 2: Boot-time re-seed on legacy plaintext admin.password_hash_source (TDD)
Clears follow-up #2 from docs/superpowers/followups.md. Any DB that predates Plan 2's admin_config encryption stores admin.password_hash_source as plaintext. On the next boot, AdminBootstrap.Seed → repo.Get(PasswordSourceKey) will pass that plaintext through EncryptionService.Decrypt, which throws CryptographicException and takes the process down. Fresh volumes (which is what Coolify starts with) don't exercise this, but we don't want a landmine buried in the code for the first time we restore a backup or migrate a dev DB.
Files:
-
Test:
tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs(new) -
Modify:
src/VolcanicCore.Api/Auth/AdminBootstrap.cs -
Step 1: Write the failing test
Create tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs:
using Microsoft.Data.Sqlite;
using VolcanicCore.Api.Auth;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Encryption;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;
using Xunit;
namespace VolcanicCore.Tests;
public class AdminBootstrapPlaintextReseedTests : IDisposable
{
private readonly string _dataDir = Path.Combine(Path.GetTempPath(), $"vc-plaintext-{Guid.NewGuid():N}");
public void Dispose()
{
try { Directory.Delete(_dataDir, recursive: true); } catch { }
}
[Fact]
public void Seed_heals_legacy_plaintext_password_source_row()
{
Directory.CreateDirectory(_dataDir);
var dbPath = Path.Combine(_dataDir, "volcaniccore.db");
var factory = new SqliteConnectionFactory(dbPath);
// Migrate the schema so admin_config exists.
using (var conn = factory.Open())
{
var mig = new Migrator(new IMigration[]
{
new Migration001Initial(),
new Migration002Connections(),
new Migration003Workflows(),
new Migration004Executions(),
new Migration005NodeExecutions(),
});
mig.Apply(conn);
}
// Simulate a pre-Plan-2 DB: write the password_hash_source as PLAINTEXT,
// bypassing AdminConfigRepository so the row is not AES-GCM encrypted.
using (var conn = factory.Open())
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO admin_config (key, value) VALUES ($k, $v)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;";
cmd.Parameters.AddWithValue("$k", AdminBootstrap.PasswordSourceKey);
cmd.Parameters.AddWithValue("$v", "legacy-plaintext-password");
cmd.ExecuteNonQuery();
// Also seed the (bcrypt) hash so the "existingHash is not null" branch runs.
using var cmd2 = conn.CreateCommand();
cmd2.CommandText = @"
INSERT INTO admin_config (key, value) VALUES ($k, $v)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;";
cmd2.Parameters.AddWithValue("$k", AdminBootstrap.PasswordHashKey);
cmd2.Parameters.AddWithValue("$v", "$2a$11$fakebcryptvaluetopasslength.check.aaaaaaaaaaaaaaaaaaaaaa");
cmd2.ExecuteNonQuery();
}
var encKey = new byte[32];
var enc = new EncryptionService(encKey);
var repo = new AdminConfigRepository(factory, enc);
var pw = new PasswordService();
// ACT — with the fix, Seed treats the un-decryptable row as "not set"
// and re-seeds it (properly encrypted this time).
AdminBootstrap.Seed(repo, pw, "the-new-env-password");
// Reading it back through the repo should now decrypt cleanly.
var restored = repo.Get(AdminBootstrap.PasswordSourceKey);
Assert.Equal("the-new-env-password", restored);
// And the hash was rewritten so the new password verifies.
var hash = repo.Get(AdminBootstrap.PasswordHashKey);
Assert.NotNull(hash);
Assert.True(pw.Verify("the-new-env-password", hash!));
}
}
- Step 2: Run the test to verify it fails
dotnet test --filter "FullyQualifiedName~AdminBootstrapPlaintextReseedTests" --nologo
Expected: FAIL with System.Security.Cryptography.CryptographicException (or similar) thrown from _enc.Decrypt(raw) — the plaintext string is not valid base64/ciphertext.
- Step 3: Add the
try/catchinAdminBootstrap.Seed
Modify src/VolcanicCore.Api/Auth/AdminBootstrap.cs — replace the body of Seed with:
using System.Security.Cryptography;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Auth;
public static class AdminBootstrap
{
public const string PasswordHashKey = "admin.password_hash";
public const string PasswordSourceKey = "admin.password_hash_source";
public static void Seed(AdminConfigRepository repo, PasswordService pw, string adminPassword)
{
if (string.IsNullOrWhiteSpace(adminPassword))
throw new InvalidOperationException(
"ADMIN_PASSWORD is required. Set the env var to a non-empty value.");
var existingHash = repo.Get(PasswordHashKey);
// Pre-Plan-2 DBs stored PasswordSourceKey as plaintext; the current
// encrypted-at-rest repo will fail to decrypt it. Which exception
// fires depends on the plaintext:
// - FormatException from Convert.FromBase64String — the plaintext
// wasn't valid base64 to begin with.
// - CryptographicException from AES-GCM verify — the string DID
// decode as base64 but the tag/nonce didn't line up.
// Treat either as "unset" so the boot re-seeds cleanly on the next
// Set() call below (which writes the row in the new encrypted format).
string? existingSource;
try
{
existingSource = repo.Get(PasswordSourceKey);
}
catch (CryptographicException)
{
existingSource = null;
}
catch (FormatException)
{
existingSource = null;
}
// Re-hash only if the env value has changed since last boot.
if (existingHash is not null && existingSource == adminPassword) return;
repo.Set(PasswordHashKey, pw.Hash(adminPassword));
// Change-detector for boot: same env value → skip re-hash; changed → re-seed.
// Stored encrypted at rest via AdminConfigRepository.DefaultSecureKeys.
repo.Set(PasswordSourceKey, adminPassword);
}
}
- Step 4: Run the test to verify it passes
dotnet test --filter "FullyQualifiedName~AdminBootstrapPlaintextReseedTests" --nologo
Expected: PASS.
- Step 5: Run the full suite
dotnet test --nologo
Expected: 77 passed (76 + 1 new).
- Step 6: Commit
git add tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs src/VolcanicCore.Api/Auth/AdminBootstrap.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "fix(auth): heal legacy plaintext admin.password_hash_source on boot"
Task 3: Add .dockerignore
Keeps host-only build artefacts and the dev DB out of the build context — required, because copying host bin//obj//node_modules/ into a Linux Docker build breaks the SDK restore (wrong RID) and inflates the context by ~500MB.
Files:
-
Create:
.dockerignore(repo root) -
Step 1: Write
.dockerignore
Create /app/data/home/VolcanicCore/.dockerignore:
# .NET
**/bin/
**/obj/
**/*.user
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/playwright-report/
frontend/test-results/
frontend/.pw-data/
# Data / smoke dirs
_smoke/
data/
# Editor / VCS
.git/
.gitignore
.vs/
.idea/
.vscode/
# Never let host secrets leak in
.env
.env.local
deploy.json
appsettings.Development.json
appsettings.Local.json
# The wwwroot in the repo is a placeholder (.gitkeep) — the frontend build
# stage produces the real one. Don't copy the empty one over the fresh build.
src/VolcanicCore.Api/wwwroot/
# Docs and repo-only files
docs/
README.md
Dockerfile
docker-compose.yml
.dockerignore
- Step 2: Commit
git add .dockerignore
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "chore(docker): add .dockerignore excluding bin/obj/node_modules/wwwroot"
Task 4: Multi-stage Dockerfile
Three stages. web builds the SPA with pnpm and emits into /build/src/VolcanicCore.Api/wwwroot/ (mirrors the repo layout so Vite's outDir: "../src/VolcanicCore.Api/wwwroot" resolves cleanly). api runs dotnet publish on the .NET solution. runtime is aspnet:9.0 with curl installed for the healthcheck; the publish output goes to /app/, the SPA overlays on top into /app/wwwroot/.
Files:
-
Create:
Dockerfile(repo root) -
Step 1: Write the Dockerfile
Create /app/data/home/VolcanicCore/Dockerfile:
# syntax=docker/dockerfile:1.7
# 1. Frontend build — pnpm 9 via corepack matches pnpm-lock.yaml lockfileVersion 9.0.
# Vite's outDir is "../src/VolcanicCore.Api/wwwroot" relative to frontend/,
# so we build under /build and pre-create the target so the .. path resolves.
FROM node:20-alpine AS web
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /build
RUN mkdir -p src/VolcanicCore.Api/wwwroot
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml frontend/
RUN cd frontend && pnpm install --frozen-lockfile
COPY frontend/ frontend/
RUN cd frontend && pnpm build
# → /build/src/VolcanicCore.Api/wwwroot/{index.html, assets/…}
# 2. Backend publish — restore cache-friendly by copying every .csproj first.
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS api
WORKDIR /src
COPY VolcanicCore.sln global.json ./
COPY src/VolcanicCore.Core/VolcanicCore.Core.csproj src/VolcanicCore.Core/
COPY src/VolcanicCore.Persistence/VolcanicCore.Persistence.csproj src/VolcanicCore.Persistence/
COPY src/VolcanicCore.Nodes/VolcanicCore.Nodes.csproj src/VolcanicCore.Nodes/
COPY src/VolcanicCore.Providers/VolcanicCore.Providers.csproj src/VolcanicCore.Providers/
COPY src/VolcanicCore.Api/VolcanicCore.Api.csproj src/VolcanicCore.Api/
RUN dotnet restore src/VolcanicCore.Api/VolcanicCore.Api.csproj
COPY src/ src/
RUN dotnet publish src/VolcanicCore.Api/VolcanicCore.Api.csproj \
-c Release -o /out --no-restore /p:UseAppHost=false
# 3. Runtime — aspnet:9.0 (Debian bookworm-slim). Not distroless: we need curl
# for HEALTHCHECK and the plain OS layer for IronPython's P/Invoke surface.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api /out ./
# Overlay the SPA build on top of the (empty) wwwroot that came from the publish.
COPY --from=web /build/src/VolcanicCore.Api/wwwroot/ ./wwwroot/
RUN mkdir -p /data
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://+:8080 \
DATA_DIRECTORY=/data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
ENTRYPOINT ["dotnet", "VolcanicCore.Api.dll"]
- Step 2: Build the image locally
docker build -t volcaniccore:local .
Expected: three stages complete; final image tagged. Watch for pnpm and dotnet warnings but nothing fatal. The image should be ~250–350MB.
- Step 3: Smoke-run the image
# Cleanup any prior test container.
docker rm -f vc-smoke 2>/dev/null || true
docker run -d --name vc-smoke -p 18080:8080 \
-e ADMIN_PASSWORD=hunter2 \
-v vc-smoke-data:/data \
volcaniccore:local
# Give it a few seconds to warm up (migrations + admin bootstrap).
sleep 3
# Health endpoint — the healthcheck target.
curl -sS http://127.0.0.1:18080/api/health
# Expected: {"ok":true}
# SPA fallback — an unknown path returns index.html.
curl -sS http://127.0.0.1:18080/ | head -5
# Expected: an <html> document (index.html contents)
# Login → 204 with a Set-Cookie for volcaniccore.sid
curl -sS -i -X POST -H 'content-type: application/json' \
-d '{"password":"hunter2"}' http://127.0.0.1:18080/api/session | head -20
# Expected: HTTP/1.1 204 No Content and Set-Cookie: volcaniccore.sid=...
# Container log should show no CryptographicException, migrations applied.
docker logs vc-smoke 2>&1 | tail -20
# Teardown
docker rm -f vc-smoke
docker volume rm vc-smoke-data
- Step 4: Commit
git add Dockerfile
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(docker): multi-stage Dockerfile — pnpm SPA build + dotnet publish + aspnet:9.0 runtime"
Task 5: docker-compose.yml (Coolify-flavoured)
Single service. expose: [8080] (not ports: — Coolify's Traefik talks to the container network directly). SERVICE_FQDN_APP_8080 tells Coolify to render the FQDN into the compose and emit Traefik routing labels. Named volume vc-data on /data so the SQLite DB survives redeploys. Env vars proxied through — Coolify substitutes ${VAR} from the app's Environment Variables UI.
Files:
-
Create:
docker-compose.yml(repo root) -
Step 1: Write the compose file
Create /app/data/home/VolcanicCore/docker-compose.yml:
services:
app:
build:
context: .
dockerfile: Dockerfile
# Coolify's Traefik connects on the container network — never publish host
# ports. Domain + TLS come from SERVICE_FQDN_APP_8080 substitution below.
expose:
- "8080"
environment:
# Coolify substitutes SERVICE_FQDN_<SVC>_<PORT> with the domain from
# docker_compose_domains and injects the Traefik router labels.
SERVICE_FQDN_APP_8080: /
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: "http://+:8080"
DATA_DIRECTORY: /data
# Secrets — set values in Coolify's Environment Variables UI.
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
# Optional tuning; Coolify falls back to Dockerfile defaults if unset.
EXECUTION_RETENTION_DAYS: ${EXECUTION_RETENTION_DAYS:-7}
EXECUTION_KEEP_PER_WORKFLOW: ${EXECUTION_KEEP_PER_WORKFLOW:-500}
WEBHOOK_DEFAULT_TIMEOUT_SECONDS: ${WEBHOOK_DEFAULT_TIMEOUT_SECONDS:-30}
MAX_CONCURRENT_EXECUTIONS: ${MAX_CONCURRENT_EXECUTIONS:-32}
volumes:
- vc-data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
vc-data:
- Step 2: Validate the compose file locally
docker compose config --quiet
Expected: exits 0 (no output). Any error means a typo — fix and re-run.
- Step 3: Commit
git add docker-compose.yml
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(deploy): coolify docker-compose — expose:8080, /data volume, SERVICE_FQDN_APP_8080"
Task 6: deploy.json — discover Coolify project/server, populate config
Non-secret Coolify config lives in deploy.json at repo root. Because the file also ends up carrying app env-var values (secrets), it must be gitignored before any real value lands in it. The user confirmed at plan-authoring time that a "VolcanicCore" project already exists on the Coolify instance — this task discovers its UUID rather than creating a new project.
Files:
-
Modify:
.gitignore(adddeploy.json) -
Create:
deploy.json(gitignored — safe to hold secrets) -
Step 1: Gitignore
deploy.jsonBEFORE writing it
grep -qxF 'deploy.json' .gitignore || echo 'deploy.json' >> .gitignore
Verify:
grep -n 'deploy.json' .gitignore
# Expected: single line "deploy.json" appears at the end of the file.
Commit the gitignore change on its own:
git add .gitignore
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "chore: gitignore deploy.json before populating"
- Step 2: Sanity-check the deploy env vars
: "${COOLIFY_URL:?set COOLIFY_URL}" "${COOLIFY_KEY:?set COOLIFY_KEY}" \
"${GIT_URL:?set GIT_URL}" "${GIT_USER:?set GIT_USER}" "${GIT_KEY:?set GIT_KEY}"
BASE="$COOLIFY_URL/api/v1"
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/version"
# Expected: a version string like "4.0.0-beta.470". If it returns "Unauthenticated"
# or 401, re-source your env before continuing.
- Step 3: Discover the existing "VolcanicCore" project UUID
BASE="$COOLIFY_URL/api/v1"
PROJECT=$(curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/projects" \
| jq -r '.[] | select((.name | ascii_downcase) == "volcaniccore") | .uuid' | head -1)
if [ -z "$PROJECT" ]; then
echo "No project named 'VolcanicCore' found. Listing all projects:"
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/projects" \
| jq -r '.[] | "\(.uuid) \(.name)"'
echo "STOP — ask the user which project UUID to use before continuing."
exit 1
fi
echo "PROJECT=$PROJECT"
Expected: a single UUID prints. If not, the exit 1 fires — surface the list to the user and get a confirmed UUID before continuing.
- Step 4: Discover the target server UUID
SERVER=$(curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/servers" \
| jq -r '.[] | select(.is_coolify_host == true) | .uuid' | head -1)
if [ -z "$SERVER" ]; then
echo "No is_coolify_host server found. Listing all:"
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/servers" \
| jq -r '.[] | "\(.uuid) \(.name) \(.ip) is_coolify_host=\(.is_coolify_host)"'
echo "STOP — ask the user which server UUID to use before continuing."
exit 1
fi
echo "SERVER=$SERVER"
Expected: a single UUID prints for the local Coolify host. If multiple servers are configured and the user runs Coolify apps on a different one, prompt the user before proceeding.
- Step 5: Check whether the app already exists under that project
APP=$(curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/applications" \
| jq -r --arg p "$PROJECT" '.[] | select(.project?.uuid == $p and .name == "volcaniccore") | .uuid' \
| head -1)
echo "APP=${APP:-<not created yet>}"
If a UUID prints, Task 7 will PATCH the existing app instead of creating a new one. If empty, Task 7 creates it.
- Step 6: Write
deploy.json
cat > deploy.json <<JSON
{
"coolify": {
"project_uuid": "$PROJECT",
"server_uuid": "$SERVER",
"environment_name": "production"
},
"app": {
"uuid": "$APP",
"name": "volcaniccore",
"description": "VolcanicCore — self-hosted N8N-style workflow engine (Plan 4 deploy)",
"git_branch": "main",
"build_pack": "dockercompose",
"ports_exposes": "8080",
"docker_compose_location": "/docker-compose.yml",
"docker_compose_domains": {
"app": { "name": "app", "domain": "https://core.tes.gd:8080" }
}
},
"env": {
"ADMIN_PASSWORD": "REPLACE_ME_BEFORE_TASK_7",
"ENCRYPTION_KEY": "REPLACE_ME_BEFORE_TASK_7"
}
}
JSON
- Step 7: Fill in the two secret values in
deploy.json
Manually replace both REPLACE_ME_BEFORE_TASK_7 values before Task 7 runs. ADMIN_PASSWORD should be a strong random string (32+ chars). ENCRYPTION_KEY must be base64-encoded 32 bytes (Program.cs validates this shape at boot and refuses to start if wrong):
# Generate suggestions to paste in (do NOT auto-inject — the user should review)
echo "Suggested ADMIN_PASSWORD (paste into deploy.json):"
openssl rand -base64 24
echo "Suggested ENCRYPTION_KEY (paste into deploy.json — this is 32 random bytes, base64):"
openssl rand -base64 32
Then hand-edit deploy.json to replace the two placeholder strings. Do NOT commit deploy.json — Step 1 gitignored it.
- Step 8: Verify
deploy.jsonis well-formed and not staged
jq -e . deploy.json > /dev/null && echo "deploy.json parses cleanly"
git status --short deploy.json
# Expected: no output (gitignored, so it doesn't appear).
- Step 9: No commit for this task
deploy.json is gitignored; nothing else changed. Task 6 lands on the previous commit. Move to Task 7.
Task 7: Create-or-PATCH the Coolify app + fire-and-forget deploy
Uses deploy.json from Task 6. Handles both fresh-create (app.uuid empty) and idempotent PATCH (app.uuid populated). After the create step the git_repository truncation bug is repaired, docker_compose_domains is PATCHed in the array form (mandatory port suffix baked in from Task 6), env vars are POSTed, and a deploy is triggered fire-and-forget per the global CLAUDE.md rule (return the deployment id; do NOT poll).
IMPORTANT ORDERING (learned the hard way on the first run): Coolify's git clone reads from origin/main at build time. The Dockerfile + docker-compose.yml + all Plan-4 code MUST be on origin/main BEFORE the deploy is triggered — otherwise Coolify clones a pre-Plan-4 ref and fails with Deployment failed: Docker Compose file not found at: /docker-compose.yml (branch: main). Run Task 8's push step BEFORE Task 7's deploy step. The physical order in this document is legacy; the correct execution order is: Tasks 1–6 → Task 8 Steps 1–7 (push everything to origin) → Task 7 (trigger deploy). Task 8's Step-6 head-SHA backfill can happen either way (it only touches CLAUDE.md, not any file Coolify reads).
Files:
-
No repo files change (all API calls).
deploy.jsongets.app.uuidwritten back on create. -
Step 1: Load env + set aliases
: "${COOLIFY_URL:?}" "${COOLIFY_KEY:?}" "${GIT_URL:?}" "${GIT_USER:?}" "${GIT_KEY:?}"
BASE="$COOLIFY_URL/api/v1"
GIT_REPOSITORY="https://$GIT_USER:$GIT_KEY@$GIT_URL"
APP=$(jq -r '.app.uuid // empty' deploy.json)
- Step 2: Create the application if the UUID is empty
if [ -z "$APP" ]; then
PAYLOAD=$(jq --arg git_repository "$GIT_REPOSITORY" '{
project_uuid: .coolify.project_uuid,
server_uuid: .coolify.server_uuid,
environment_name: .coolify.environment_name
} + (.app | del(.uuid)) + {git_repository: $git_repository, instant_deploy: false}' deploy.json)
APP=$(curl -sS -X POST "$BASE/applications/public" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d "$PAYLOAD" \
2>&1 | sed -E "s|${GIT_KEY}|***|g; s|${GIT_USER}|***|g" \
| jq -r '.uuid')
if [ -z "$APP" ] || [ "$APP" = "null" ]; then
echo "Create failed. Full response:" >&2
curl -sS -X POST "$BASE/applications/public" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d "$PAYLOAD" 2>&1 | sed -E "s|${GIT_KEY}|***|g; s|${GIT_USER}|***|g" >&2
exit 1
fi
# Persist the new UUID so re-runs skip create.
jq --arg v "$APP" '.app.uuid = $v' deploy.json > deploy.json.tmp && mv deploy.json.tmp deploy.json
echo "Created APP=$APP"
else
echo "App already exists: APP=$APP — skipping create, going straight to PATCH."
fi
Expected: APP holds a UUID; deploy.json has .app.uuid populated.
- Step 3: PATCH
git_repositoryto repair the truncation bug
The create endpoint stores git_repository as owner/repo.git, dropping host + credentials. This PATCH restores the full URL. It is safe (idempotent) to run even on an already-populated app.
curl -sS -X PATCH "$BASE/applications/$APP" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg git_repository "$GIT_REPOSITORY" '{git_repository: $git_repository}')" \
2>&1 | sed -E "s|${GIT_KEY}|***|g; s|${GIT_USER}|***|g" \
> /dev/null
# Verify — the stored value should now be the full URL (with creds).
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/applications/$APP" \
| jq -r '.git_repository' \
| sed -E "s|${GIT_KEY}|***|g; s|${GIT_USER}|***|g"
# Expected: https://***:***@<host>/…/…git
- Step 4: PATCH
docker_compose_domains(array form) with the port-suffixed domain
Even if the create payload included the object form, we PATCH the array form now because (a) the object form is stored inconsistently on some Coolify versions, (b) PATCH is idempotent, and (c) the array form is what proves the routing labels will get emitted.
curl -sS -X PATCH "$BASE/applications/$APP" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d "$(jq -c '.app.docker_compose_domains
| to_entries
| map({name: .key, domain: .value.domain})
| {docker_compose_domains: .}' deploy.json)" \
> /dev/null
# Verify — the stored value must include the :8080 suffix.
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/applications/$APP" \
| jq '.docker_compose_domains'
# Expected:
# {
# "app": { "name": "app", "domain": "https://core.tes.gd:8080" }
# }
If the port suffix is missing here, Traefik will 500 every request even though the container is healthy — re-check deploy.json's .app.docker_compose_domains.app.domain and PATCH again.
- Step 5: POST env vars (
ADMIN_PASSWORD,ENCRYPTION_KEY, others if you set them)
jq -r '.env | to_entries[] | "\(.key)=\(.value)"' deploy.json |
while IFS='=' read -r k v; do
# Skip placeholders that still say REPLACE_ME…
if [[ "$v" == REPLACE_ME* ]]; then
echo "SKIP $k (still placeholder) — edit deploy.json first" >&2
exit 1
fi
# First try POST; if the env already exists, PATCH by key.
code=$(curl -sS -o /tmp/env-resp -w '%{http_code}' \
-X POST "$BASE/applications/$APP/envs" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg k "$k" --arg v "$v" '{key:$k,value:$v,is_preview:false}')")
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
# Assume duplicate — PATCH it.
curl -sS -X PATCH "$BASE/applications/$APP/envs" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg k "$k" --arg v "$v" '{key:$k,value:$v,is_preview:false}')" \
> /dev/null
fi
echo "env $k set"
done
Expected: one "env X set" line per env-var entry. No REPLACE_ME warning.
- Step 6: Verify what actually landed on Coolify
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" "$BASE/applications/$APP" \
| jq '{name, build_pack, ports_exposes, docker_compose_location, docker_compose_domains, git_branch, git_repository: (.git_repository | sub("//[^@]+@"; "//***:***@"))}'
Expected: build_pack == "dockercompose", ports_exposes == "8080", docker_compose_domains.app.domain == "https://core.tes.gd:8080", git_repository = the redacted full URL to Gitea. If any of these are wrong, PATCH them and re-verify BEFORE deploying.
- Step 7: Trigger deploy — fire-and-forget
Per the global CLAUDE.md rule: kick off the deploy, capture the deployment UUID, and stop. Do NOT poll /deployments/<id> or curl the FQDN waiting for the build. The user will verify on their own time.
DEPLOY_UUID=$(curl -sS -X POST "$BASE/deploy?uuid=$APP" \
-H "Authorization: Bearer $COOLIFY_KEY" \
| jq -r '.deployments[0].deployment_uuid // .deployment_uuid')
echo "Deploy triggered: deployment_uuid=$DEPLOY_UUID"
echo "Live URL (once the build finishes): https://core.tes.gd"
echo "Coolify build log: $COOLIFY_URL/project/$(jq -r '.coolify.project_uuid' deploy.json)/environment/production/application/$APP/deployment/$DEPLOY_UUID"
Expected: deployment_uuid is a non-empty UUID string. That is the success signal. STOP HERE — do not poll.
- Step 8: No commit for this task
Task 7 makes only remote API calls plus updates to gitignored deploy.json. Nothing to commit.
Task 8: Playwright CI + close-out — update CLAUDE.md, clear followups.md
Two loose ends:
- The Playwright browser install (
pnpm exec playwright install chromium) is documented in Plan 3's close-out. Plan 4 doesn't wire CI (no.github/workflows/yet); adding CI is out of scope for the initial-launch scope the user set at Plan-4 kickoff. Log it as a Plan-5 follow-up so we don't silently drop it. - Update
CLAUDE.mdwith the live URL, the current deploy invocation, and the Docker-runtime invariants. - Remove the two Plan-4 items from
docs/superpowers/followups.mdsince Tasks 1 + 2 cleared them.
Files:
-
Modify:
CLAUDE.md -
Modify:
docs/superpowers/followups.md -
Step 1: Log CI-Playwright as a Plan-5 follow-up
Rewrite docs/superpowers/followups.md — remove the two Plan-4 items and add a Plan-5 item for CI:
# Cross-Plan Follow-ups
Items surfaced during review of one plan that belong to a later plan. Prevents silent drops.
## Deferred to Plan 5 (CI + observability)
- **CI pipeline** — There is no automated CI yet. Add a workflow (Gitea Actions if the repo is hosted on Gitea, otherwise GitHub Actions) that runs `dotnet build`, `dotnet test`, `pnpm --dir frontend test`, `pnpm --dir frontend build`, and the Playwright smoke on push to `main`. The Playwright job needs `pnpm exec playwright install --with-deps chromium` before `pnpm --dir frontend test:e2e`. Bonus: build + push the Docker image on tag pushes.
- Step 2: Update
CLAUDE.md— status block + Docker invariants + Deploy target
Modify the "Current status" section — bump the plan-3 line to describe Plan 4 shipping. Insert the following block right below the existing "Plan 3 (React SPA) shipped." block. Also swap the "What's next" bullet list for a Live URL + close-out note.
Replace:
What's next: **Plan 4** — Dockerfile + Coolify deploy. Not yet written. When authored, it will cover:
- Multi-stage Dockerfile: pnpm build the SPA into `wwwroot`, `dotnet publish` the backend, distroless runtime image.
- Coolify docker-compose using `preparing-dotnet-react-app-for-coolify` skill (named volume for `/data`, `expose: [8080]`, `SERVICE_FQDN_APP`).
- Wire `UseForwardedHeaders` + `KnownProxies` so Secure cookies work behind Traefik (Plan-2 follow-up).
- Boot-time re-seed of legacy plaintext `admin.password_hash_source` (Plan-2 follow-up).
- CI install of Playwright's Chromium (Plan-3 follow-up if needed).
- v4 API deploy trigger — fire-and-forget per global CLAUDE.md rule.
with:
**Plan 4 (Container + Coolify deploy) shipped.** Head commit at plan close: `<REPLACE-WITH-HEAD-SHA>`. `dotnet test` 77/77 (2 new: forwarded-headers + admin-plaintext-reseed), Docker image builds and smoke-runs locally, live at **https://core.tes.gd** via Coolify.
What shipped in addition to Plans 1 + 2 + 3:
- Multi-stage `Dockerfile` (web → api → runtime); runtime image `mcr.microsoft.com/dotnet/aspnet:9.0` with `curl` for the compose healthcheck.
- `docker-compose.yml` with `expose: [8080]`, named volume `vc-data` on `/data`, `SERVICE_FQDN_APP_8080` for Coolify/Traefik routing.
- `.dockerignore` keeps `bin/`, `obj/`, `frontend/node_modules/`, `frontend/dist/`, `src/VolcanicCore.Api/wwwroot/`, and `deploy.json` out of the build context.
- `Program.cs` wires `UseForwardedHeaders` (XForwardedProto + XForwardedFor, cleared KnownNetworks/KnownProxies) **before** `UseAuthentication`, so cookie SecurePolicy sees TLS behind Traefik.
- `AdminBootstrap.Seed` catches `CryptographicException` on the legacy plaintext `admin.password_hash_source` row and re-seeds cleanly.
- `deploy.json` (gitignored) holds non-secret Coolify config + secret env values; Coolify project uuid discovered at deploy time.
What's next: **initial launch scope complete.** Post-launch work goes to Plan 5 (CI + observability) — see `docs/superpowers/followups.md`.
- Step 3: Extend the Invariants section of
CLAUDE.md
Insert the following bullets at the end of the existing "Invariants" list (i.e. just before the "Working style" heading):
- **Docker runtime is `aspnet:9.0`, not distroless.** Distroless has no `curl` (breaks the compose healthcheck) and hasn't been vetted against IronPython's P/Invoke surface. If you ever switch, install a healthcheck binary and test `pnpm --dir frontend test:e2e` against the container.
- **Vite `outDir` is `../src/VolcanicCore.Api/wwwroot` (repo-relative to `frontend/`).** The Dockerfile mirrors this layout — the `web` stage does `mkdir -p src/VolcanicCore.Api/wwwroot` under `/build/` before running `pnpm build`, so Vite's `..` path resolves cleanly. If you change `vite.config.ts`'s `build.outDir`, update the Dockerfile in lockstep.
- **`UseForwardedHeaders` runs BEFORE `UseAuthentication`.** Cookie `SecurePolicy.SameAsRequest` only flips the `Secure` flag when `Request.IsHttps == true`, which only happens once the forwarded-headers middleware has rewritten the scheme. Moving `UseAuthentication` above `UseForwardedHeaders` silently breaks HTTPS cookies in prod.
- **`SERVICE_FQDN_APP_8080` (not `SERVICE_FQDN_APP`).** Coolify v4 requires the port suffix in the env-var name AND in the `docker_compose_domains[svc].domain` string (`https://core.tes.gd:8080`). Without either, Traefik has no idea which container port to route to and returns HTTP 500.
- **`deploy.json` is gitignored** — it carries secret app env values in `.env`. Never commit; if it ever gets committed, rotate every value in it.
- Step 4: Add a "Deploy" section to CLAUDE.md replacing the existing "Deploy target" block
Replace the existing ## Deploy target block in CLAUDE.md with the exact content between the <<<BEGIN>>> and <<<END>>> markers below. (The markers are just delimiters for you as the engineer — do NOT put them in CLAUDE.md. Use Edit/sed to replace the block; do not wrap the content in additional fences.)
<<>>
Deploy
Live: https://core.tes.gd
Trigger a redeploy (fire-and-forget per global CLAUDE.md rule):
# Requires COOLIFY_URL, COOLIFY_KEY exported and deploy.json populated.
APP=$(jq -r '.app.uuid' deploy.json)
curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP" \
-H "Authorization: Bearer $COOLIFY_KEY" \
| jq -r '.deployments[0].deployment_uuid // .deployment_uuid'
# → prints deployment_uuid; DO NOT poll. User verifies on their own time.
Env vars available in the container: COOLIFY_URL, COOLIFY_KEY, GIT_URL, GIT_USER, GIT_KEY. Actual container packaging + Coolify wiring shipped in Plan 4. Global CLAUDE.md rule applies: fire-and-forget deploys via the v4 API, never block on build.
Update env vars on the live app (e.g. rotate ADMIN_PASSWORD):
APP=$(jq -r '.app.uuid' deploy.json)
curl -sS -X PATCH "$COOLIFY_URL/api/v1/applications/$APP/envs" \
-H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \
-d '{"key":"ADMIN_PASSWORD","value":"<new value>","is_preview":false}'
<<>>
When writing the block into CLAUDE.md, the shell code blocks should use standard triple-backticks ( bash and ) — the tilde fences (~~~) in this plan are only to avoid nested-fence rendering issues in the plan file itself.
- Step 5: Commit CLAUDE.md + followups.md
git add CLAUDE.md docs/superpowers/followups.md
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "docs: close out Plan 4 — live at core.tes.gd, clear Plan-4 follow-ups"
- Step 6: Backfill the head SHA in CLAUDE.md
The status line has <REPLACE-WITH-HEAD-SHA> as a placeholder because the commit hasn't happened yet at Step 2. After the Step-5 commit, run:
HEAD_SHA=$(git rev-parse --short HEAD)
sed -i "s/<REPLACE-WITH-HEAD-SHA>/$HEAD_SHA/" CLAUDE.md
git add CLAUDE.md
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "docs: pin Plan-4 close-out head SHA"
- Step 7: Push to origin
git push origin main 2>&1 | sed -E "s|${GIT_KEY:-NOPE}|***|g; s|${GIT_USER:-NOPE}|***|g"
Expected: fast-forward push, all Plan-4 commits land on origin/main. This makes them reachable by Coolify's git clone.
Success criteria
dotnet testreports 77 passed — 74 previous + 3 new (2 inForwardedHeadersTests, 1 inAdminBootstrapPlaintextReseedTests).pnpm --dir frontend testunchanged at 20 passed (no frontend changes in this plan).docker build -t volcaniccore:local .succeeds and produces an image ~250–350MB.docker runof that image serves{"ok":true}at/api/healthon port 8080 and returns the SPAindex.htmlon/.POST /api/sessionwithX-Forwarded-Proto: httpsreturns aSet-Cookie: volcaniccore.sid=…; secure; …header (via the test suite).- Coolify app for VolcanicCore exists (create-or-PATCH), env vars are set,
docker_compose_domainsshows{"name":"app","domain":"https://core.tes.gd:8080"}. - A deployment id came back from
POST /deploy?uuid=<app>. No polling. No blocking on the FQDN. docs/superpowers/followups.mdno longer has any Plan-4 items; it has one Plan-5 item (CI).CLAUDE.mdreflects the live URL and the new Docker invariants.
What is explicitly NOT in this plan
- CI pipeline (Gitea Actions / GitHub Actions) — deferred to Plan 5.
- Blocking on the Coolify build — global CLAUDE.md forbids it; verification is by the user, on their own time, hitting
https://core.tes.gd. - Any changes to the SPA. Plan 3 shipped complete.
- Any new nodes or providers. Plan 2 shipped complete.
- Merge-node, retry policies, multi-user, sub-workflows — spec §10 deferrals.