fix(mcp v2.4.8): use Coolify's :port URL convention, drop 170 lines of post-deploy hacks
The Coolify UI shows a "Required Port: 3000 — All domains must
include this port number" hint on service templates. That hint is
load-bearing: when the URL passed to `setServiceDomains` includes
:<upstream_port>, Coolify's template engine auto-generates everything
that 2.4.5-2.4.7 were doing by hand:
- traefik.http.services.<svc>.loadbalancer.server.port label
- SERVICE_FQDN_<APP>=<fqdn> (no sslip.io leak)
- SERVICE_URL_<APP>=https://<fqdn>
- SERVICE_FQDN_<APP>_<PORT>=<fqdn>:<port>
- SERVICE_URL_<APP>_<PORT>=https://<fqdn>:<port>
Verified end-to-end with twenty:
setServiceDomains(uuid, [{ name:'twenty', url:'https://crm.mark.vibnai.com:3000' }])
followed by `compose up -d --force-recreate twenty` produced HTTP/2
200 from https://crm.mark.vibnai.com on first hit, with the
loadbalancer label present, .env clean, and zero env-rewriting
required.
Changes:
- apps.create template path now reads template.port from the catalog
and calls setServiceDomains with https://<fqdn>:<port>
- listServiceTemplates now accepts port as either number or numeric
string (Coolify ships both shapes in the catalog)
- applyCoolifyPostDeployFixes simplified from ~200 lines to ~50:
drops env rewrite, label injection, and force-recreate steps;
keeps proxy network attach + (background) proxy restart
- CoolifyPostDeployResult.steps shrinks to { proxyNetwork, proxyRestart }
- Removes the python:3-alpine SSH dependency entirely
- buildPythonRunner helper removed
Made-with: Cursor
This commit is contained in:
@@ -92,7 +92,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
name: 'vibn-mcp',
|
||||
version: '2.4.7',
|
||||
version: '2.4.8',
|
||||
authentication: {
|
||||
scheme: 'Bearer',
|
||||
tokenPrefix: 'vibn_sk_',
|
||||
@@ -837,6 +837,15 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
||||
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
|
||||
if (fqdn instanceof NextResponse) return fqdn;
|
||||
|
||||
// Pull the template's required upstream port from the catalog.
|
||||
// Coolify's "Required Port" UI hint says: domains MUST be specified
|
||||
// as host:port for the template engine to wire up the right
|
||||
// SERVICE_FQDN_<APP>_<port> magic env, the loadbalancer.server.port
|
||||
// Traefik label, and the SERVICE_URL_<APP>_<port> env. Without it
|
||||
// we get the default sslip.io values everywhere and Traefik returns
|
||||
// 503 because the routing rules have no port to forward to.
|
||||
const templatePort = catalog[templateSlug]?.port ?? 3000;
|
||||
|
||||
const created = await createService({
|
||||
projectUuid: commonOpts.projectUuid,
|
||||
serverUuid: commonOpts.serverUuid,
|
||||
@@ -851,22 +860,17 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
||||
});
|
||||
|
||||
// Coolify auto-assigns sslip.io URLs. Replace them with the
|
||||
// user's FQDN. We rebuild the urls array by reading the service
|
||||
// back to learn the docker-compose service names (template-specific).
|
||||
// user's FQDN, INCLUDING the required upstream port — see comment
|
||||
// on `templatePort` above. The :port suffix is what makes Coolify
|
||||
// generate the loadbalancer.server.port label and substitute the
|
||||
// SERVICE_FQDN_<APP> env to the user's host (no sslip.io leak).
|
||||
let urlsApplied = false;
|
||||
try {
|
||||
// Brief settle so the service is fully committed
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const svc = await getService(created.uuid) as Record<string, unknown>;
|
||||
// Coolify stores per-service urls under different shapes across versions:
|
||||
// - service.fqdn : "https://x.sslip.io,https://y.sslip.io"
|
||||
// - service.urls : [{ name, url }]
|
||||
// For simplicity, target the docker-compose service named after
|
||||
// the template slug (covers ~90% of templates: twenty, n8n, ghost,
|
||||
// wordpress, etc). Users can adjust later via apps.domains.set.
|
||||
await setServiceDomains(created.uuid, [{ name: templateSlug, url: `https://${fqdn}` }]);
|
||||
await setServiceDomains(created.uuid, [
|
||||
{ name: templateSlug, url: `https://${fqdn}:${templatePort}` },
|
||||
]);
|
||||
urlsApplied = true;
|
||||
void svc; // reserved for future heuristic
|
||||
} catch (e) {
|
||||
console.warn('[mcp apps.create/template] setServiceDomains failed', e);
|
||||
}
|
||||
@@ -892,6 +896,7 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
||||
uuid: created.uuid,
|
||||
fqdn,
|
||||
publicAppName: templateSlug,
|
||||
port: templatePort,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,33 @@
|
||||
/**
|
||||
* Surgical post-deploy fixes for Coolify-managed Services.
|
||||
* Surgical post-deploy fix for Coolify-managed Services.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* Coolify's service-template deploy pipeline gets us 90% of the way:
|
||||
* it generates a docker-compose.yml + .env, runs `docker compose up`,
|
||||
* sets up volumes, and writes Traefik labels. But for many templates
|
||||
* (including the popular twenty/n8n/ghost/etc.) it consistently fails
|
||||
* to do three host-level things that the public REST API does NOT
|
||||
* expose:
|
||||
* Coolify's service-template deploy pipeline gets us 99% of the way
|
||||
* — IF apps.create passes the upstream port in the URL it gives to
|
||||
* `setServiceDomains` (e.g. `https://crm.mark.vibnai.com:3000`).
|
||||
* With that port suffix Coolify auto-generates everything that
|
||||
* matters: the loadbalancer.server.port Traefik label, the rewritten
|
||||
* SERVICE_FQDN_<APP> / SERVICE_URL_<APP> env vars (no sslip.io
|
||||
* leakage), and the correct routing rules.
|
||||
*
|
||||
* 1. Rewrite the auto-generated `SERVICE_FQDN_*` / `SERVICE_URL_*`
|
||||
* env vars from sslip.io defaults to the user's real FQDN. The
|
||||
* user's domain is correctly stored on `service.applications[].fqdn`
|
||||
* (so Traefik routing rules use it), but the env vars that the
|
||||
* app embeds into its frontend bundle (e.g. Twenty's SERVER_URL)
|
||||
* keep pointing at sslip.io. Result: SPA loads on real HTTPS
|
||||
* then makes XHRs to insecure sslip.io URLs → "Mixed Content"
|
||||
* errors and the app appears broken.
|
||||
* The one thing Coolify still misses is connecting `coolify-proxy`
|
||||
* to the resource's project Docker network. Coolify writes a
|
||||
* `caddy_ingress_network=<uuid>` hint label but never runs
|
||||
* `docker network connect`, so Traefik discovers the right routing
|
||||
* rules but cannot reach the upstream container — every request
|
||||
* returns Traefik 503.
|
||||
*
|
||||
* 2. Generate the `traefik.http.services.<svc>.loadbalancer.server.port`
|
||||
* label. Without it Traefik logs `error: port is missing` and
|
||||
* returns 503 on every request.
|
||||
* That's the entire purpose of this module: attach `coolify-proxy`
|
||||
* to the project network, then nudge Traefik to re-discover.
|
||||
*
|
||||
* 3. Connect `coolify-proxy` to the resource's project network.
|
||||
* Coolify generates a label `caddy_ingress_network=<uuid>`
|
||||
* hinting that the proxy SHOULD live there, but never actually
|
||||
* runs `docker network connect`. Result: even if Traefik
|
||||
* discovers the right routing rules, it can't reach the upstream
|
||||
* container.
|
||||
*
|
||||
* This module fixes all three after Coolify's queue finishes its work.
|
||||
*
|
||||
* Permissions model
|
||||
* -----------------
|
||||
* The `vibn-logs` SSH user has docker-group membership but no shell
|
||||
* sudo and no read access to `/data/coolify/services/<uuid>/` (Coolify
|
||||
* chmods that to 0700 root). We work around both by running a one-shot
|
||||
* `python:alpine` container that bind-mounts the path. The docker
|
||||
* daemon runs as root so it can read the directory; vibn-logs only
|
||||
* needs the docker socket.
|
||||
* History
|
||||
* -------
|
||||
* Versions 2.4.5 → 2.4.7 also rewrote `.env` and injected the
|
||||
* loadbalancer port label via an embedded Python script run inside a
|
||||
* `python:3-alpine` container. That code became unnecessary in 2.4.8
|
||||
* once we discovered the `:port` URL convention; it's been removed
|
||||
* along with the `python:alpine` SSH dependency.
|
||||
*/
|
||||
|
||||
import { runOnCoolifyHost, type CoolifySshResult } from './coolify-ssh';
|
||||
@@ -142,9 +130,10 @@ export interface CoolifyPostDeployOptions {
|
||||
/** Compose service name of the user-facing app, e.g. "twenty". */
|
||||
publicAppName: string;
|
||||
/**
|
||||
* HTTP port the public app listens on inside the container.
|
||||
* If omitted, we try to detect it from `.env` (looking for
|
||||
* `SERVICE_FQDN_<APP>_<PORT>`). Falls back to 3000.
|
||||
* HTTP port the public app listens on inside the container. Optional
|
||||
* here — kept for back-compat and diagnostics; the actual port
|
||||
* routing is wired by Coolify itself based on the URL passed to
|
||||
* setServiceDomains, not by this helper.
|
||||
*/
|
||||
port?: number;
|
||||
}
|
||||
@@ -152,176 +141,44 @@ export interface CoolifyPostDeployOptions {
|
||||
export interface CoolifyPostDeployResult {
|
||||
ok: boolean;
|
||||
steps: {
|
||||
envRewrite: { ok: boolean; detail: string };
|
||||
portLabel: { ok: boolean; detail: string };
|
||||
proxyNetwork: { ok: boolean; detail: string };
|
||||
recreate: { ok: boolean; detail: string };
|
||||
proxyRestart: { ok: boolean; detail: string };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a Python script (UTF-8 bytes, base64-encoded) as a here-doc
|
||||
* arg to a docker-run that mounts the resource's compose dir at /work
|
||||
* and exposes the inputs as env vars. We use base64 to sidestep all
|
||||
* shell-escaping issues with python triple-quoted strings.
|
||||
*/
|
||||
function buildPythonRunner(script: string, env: Record<string, string>, dir: string, networkAttach = false): string {
|
||||
const b64 = Buffer.from(script, 'utf8').toString('base64');
|
||||
const envFlags = Object.entries(env)
|
||||
.map(([k, v]) => `-e ${sq(`${k}=${v}`)}`)
|
||||
.join(' ');
|
||||
// We need a Python image with sed-style file editing. python:3-alpine
|
||||
// is ~50MB and ships with regex + os out of the box.
|
||||
return [
|
||||
`echo ${sq(b64)} | base64 -d |`,
|
||||
'docker run --rm -i',
|
||||
`-v ${sq(`${dir}:/work`)}`,
|
||||
networkAttach ? '-v /var/run/docker.sock:/var/run/docker.sock' : '',
|
||||
envFlags,
|
||||
'python:3-alpine',
|
||||
'python -',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the three post-deploy fixes to a freshly-deployed Coolify
|
||||
* service so the user-facing URL works on the very first hit.
|
||||
* Apply the post-deploy fix to a freshly-deployed Coolify service so
|
||||
* the user-facing URL works on the very first hit.
|
||||
*
|
||||
* Idempotent. Safe to call multiple times — each step detects
|
||||
* whether the change is already in place and no-ops if so.
|
||||
* Idempotent. Safe to call multiple times. Coolify-version-tolerant —
|
||||
* if a future Coolify already attaches the proxy network itself, both
|
||||
* steps no-op cleanly.
|
||||
*
|
||||
* Sequencing:
|
||||
* 1. Rewrite .env's SERVICE_FQDN_* / SERVICE_URL_* (cosmetic for
|
||||
* Traefik but critical for any frontend that bakes the URL into
|
||||
* its bundle from these env vars at startup).
|
||||
* 2. Inject the missing `loadbalancer.server.port` label into the
|
||||
* compose file.
|
||||
* 3. Connect coolify-proxy to the project network so Traefik can
|
||||
* reach the public container by its compose name.
|
||||
* 4. `docker compose up -d --force-recreate <publicAppName>` — this
|
||||
* applies the new env (step 1) and label (step 2) without
|
||||
* touching internal services like postgres/redis (which would
|
||||
* cause DNS collisions if their networks changed).
|
||||
* 5. `docker restart coolify-proxy` so Traefik re-discovers the
|
||||
* newly-attached network and the recreated container's labels.
|
||||
* 1. `docker network connect <uuid> coolify-proxy` so Traefik can
|
||||
* reach the public container by its compose name. This is the
|
||||
* ONE thing Coolify omits despite writing the
|
||||
* `caddy_ingress_network=<uuid>` hint label.
|
||||
* 2. Background `docker restart coolify-proxy` (fired off via
|
||||
* nohup) so Traefik re-discovers the newly-attached network. We
|
||||
* can't restart it synchronously because coolify-proxy is the
|
||||
* same gateway serving this very HTTP request — see step 2's
|
||||
* comment for the gory detail.
|
||||
*/
|
||||
export async function applyCoolifyPostDeployFixes(
|
||||
opts: CoolifyPostDeployOptions,
|
||||
): Promise<CoolifyPostDeployResult> {
|
||||
const { uuid, fqdn, publicAppName, port = 3000 } = opts;
|
||||
const dir = composeDir('service', uuid);
|
||||
const { uuid } = opts;
|
||||
|
||||
const result: CoolifyPostDeployResult = {
|
||||
ok: false,
|
||||
steps: {
|
||||
envRewrite: { ok: false, detail: '' },
|
||||
portLabel: { ok: false, detail: '' },
|
||||
proxyNetwork: { ok: false, detail: '' },
|
||||
recreate: { ok: false, detail: '' },
|
||||
proxyRestart: { ok: false, detail: '' },
|
||||
},
|
||||
};
|
||||
|
||||
// ── Step 1+2 fused: rewrite .env + inject port label in one Python pass
|
||||
const editorScript = `
|
||||
import os, re, sys
|
||||
|
||||
env_file = "/work/.env"
|
||||
compose_file = "/work/docker-compose.yml"
|
||||
fqdn = os.environ["NEW_FQDN"]
|
||||
app = os.environ["APP"] # e.g. "twenty"
|
||||
APP = app.upper()
|
||||
uuid = os.environ["UUID"]
|
||||
port = os.environ["PORT"]
|
||||
|
||||
env_changes = []
|
||||
if os.path.exists(env_file):
|
||||
with open(env_file, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
new = line
|
||||
# SERVICE_FQDN_<APP>=<host>
|
||||
if re.match(rf"^SERVICE_FQDN_{re.escape(APP)}=", line):
|
||||
new = f"SERVICE_FQDN_{APP}={fqdn}\\n"
|
||||
# SERVICE_URL_<APP>=<scheme://host>
|
||||
elif re.match(rf"^SERVICE_URL_{re.escape(APP)}=", line):
|
||||
new = f"SERVICE_URL_{APP}=https://{fqdn}\\n"
|
||||
else:
|
||||
m = re.match(rf"^SERVICE_FQDN_{re.escape(APP)}_(\\d+)=", line)
|
||||
if m:
|
||||
new = f"SERVICE_FQDN_{APP}_{m.group(1)}={fqdn}:{m.group(1)}\\n"
|
||||
else:
|
||||
m = re.match(rf"^SERVICE_URL_{re.escape(APP)}_(\\d+)=", line)
|
||||
if m:
|
||||
new = f"SERVICE_URL_{APP}_{m.group(1)}=https://{fqdn}:{m.group(1)}\\n"
|
||||
if new != line:
|
||||
env_changes.append(line.strip() + " => " + new.strip())
|
||||
out.append(new)
|
||||
with open(env_file, "w", encoding="utf-8") as f:
|
||||
f.writelines(out)
|
||||
|
||||
# Inject port label into compose if missing.
|
||||
label_changes = []
|
||||
svc_id = f"{app}-svc-{uuid}"
|
||||
needed_router_svc = f"traefik.http.routers.https-0-{uuid}-{app}.service={svc_id}"
|
||||
needed_loadbalance = f"traefik.http.services.{svc_id}.loadbalancer.server.port={port}"
|
||||
http_router_svc = f"traefik.http.routers.http-0-{uuid}-{app}.service={svc_id}"
|
||||
|
||||
with open(compose_file, "r", encoding="utf-8") as f:
|
||||
s = f.read()
|
||||
|
||||
if needed_loadbalance not in s:
|
||||
# Anchor: the existing tls=true label for the https router.
|
||||
anchor = f"traefik.http.routers.https-0-{uuid}-{app}.tls=true"
|
||||
if anchor in s:
|
||||
replacement = (
|
||||
anchor
|
||||
+ "\\n - " + http_router_svc
|
||||
+ "\\n - " + needed_router_svc
|
||||
+ "\\n - " + needed_loadbalance
|
||||
)
|
||||
s = s.replace(anchor, replacement, 1) # only on the twenty service block
|
||||
with open(compose_file, "w", encoding="utf-8") as f:
|
||||
f.write(s)
|
||||
label_changes.append(f"injected loadbalancer.server.port={port}")
|
||||
else:
|
||||
label_changes.append(f"WARN: anchor '{anchor}' not found; label NOT injected")
|
||||
else:
|
||||
label_changes.append("loadbalancer.server.port already present")
|
||||
|
||||
print("ENV_CHANGES:" + str(len(env_changes)))
|
||||
for c in env_changes:
|
||||
print(" " + c)
|
||||
print("LABEL_CHANGES:")
|
||||
for c in label_changes:
|
||||
print(" " + c)
|
||||
`;
|
||||
|
||||
try {
|
||||
const cmd = buildPythonRunner(
|
||||
editorScript,
|
||||
{ NEW_FQDN: fqdn, APP: publicAppName, UUID: uuid, PORT: String(port) },
|
||||
dir,
|
||||
);
|
||||
const r = await runOnCoolifyHost(cmd, { timeoutMs: 60_000 });
|
||||
if (r.code === 0) {
|
||||
const text = r.stdout.trim().slice(-1500);
|
||||
result.steps.envRewrite = { ok: true, detail: text };
|
||||
result.steps.portLabel = { ok: !text.includes('WARN:'), detail: text };
|
||||
} else {
|
||||
const detail = (r.stderr || r.stdout).trim().slice(-500);
|
||||
result.steps.envRewrite = { ok: false, detail };
|
||||
result.steps.portLabel = { ok: false, detail };
|
||||
}
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
result.steps.envRewrite = { ok: false, detail };
|
||||
result.steps.portLabel = { ok: false, detail };
|
||||
}
|
||||
|
||||
// ── Step 3: attach coolify-proxy to project network
|
||||
// ── Step 1: attach coolify-proxy to project network
|
||||
try {
|
||||
// `|| true` swallows the "endpoint with name coolify-proxy already
|
||||
// exists in network" error which is the success-already-applied case.
|
||||
@@ -342,37 +199,7 @@ for c in label_changes:
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 4: recreate ONLY the public app to apply env+label changes
|
||||
// (not the whole stack — postgres/redis/worker stay where they are)
|
||||
try {
|
||||
const r = await composeRun('service', uuid, ['up', '-d', '--force-recreate', publicAppName], {
|
||||
timeoutMs: 300_000,
|
||||
});
|
||||
const detail = (r.stderr || r.stdout)
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/[\x00-\x08\x0B-\x1F]/g, '')
|
||||
.trim()
|
||||
.slice(-400);
|
||||
// compose returns 0 on success, non-zero on partial failure;
|
||||
// sidecar `depends_on` timeouts can produce a non-zero exit
|
||||
// even though the public container started successfully.
|
||||
const probe = await runOnCoolifyHost(
|
||||
`docker ps --filter name=${publicAppName}-${uuid} --format '{{.Names}}'`,
|
||||
{ timeoutMs: 8_000 },
|
||||
);
|
||||
const running = probe.stdout.trim().length > 0;
|
||||
result.steps.recreate = {
|
||||
ok: running,
|
||||
detail: running ? `${publicAppName}-${uuid} running` : detail,
|
||||
};
|
||||
} catch (e) {
|
||||
result.steps.recreate = {
|
||||
ok: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 5: nudge Traefik to re-discover via proxy restart.
|
||||
// ── Step 2: nudge Traefik to re-discover via proxy restart.
|
||||
//
|
||||
// CAUTION: coolify-proxy is the same gateway that's currently
|
||||
// serving this very HTTP request (the agent → vibnai.com call that
|
||||
|
||||
@@ -979,7 +979,13 @@ export async function listServiceTemplates(opts: { force?: boolean } = {}): Prom
|
||||
tags: Array.isArray(t.tags) ? t.tags.filter((x): x is string => typeof x === 'string') : undefined,
|
||||
category: typeof t.category === 'string' ? t.category : undefined,
|
||||
logo: typeof t.logo === 'string' ? t.logo : undefined,
|
||||
port: typeof t.port === 'number' ? t.port : undefined,
|
||||
// Coolify's catalog stores port as either a number (e.g. 3000)
|
||||
// or a numeric string (e.g. "3000") — handle both.
|
||||
port: typeof t.port === 'number'
|
||||
? t.port
|
||||
: typeof t.port === 'string' && /^\d+$/.test(t.port.trim())
|
||||
? Number(t.port.trim())
|
||||
: undefined,
|
||||
documentation: typeof t.documentation === 'string' ? t.documentation : undefined,
|
||||
minversion: typeof t.minversion === 'string' ? t.minversion : undefined,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user