Skip to content

Commit 9bdcf81

Browse files
fix(ci): give each preview its own network and resolvable services
The first real preview deployment failed with UnknownHostException: postgres. Coolify renames every compose service to `<service>-pr-<id>`, so the bare service name resolves to nothing inside a preview; it publishes the renamed hosts as SERVICE_NAME_*, which the appserver now uses for its database and NATS URLs. Diagnosing that surfaced a second defect. Coolify runs every preview of an application under one Compose project, named after the application UUID with no pull request in it, so the networks this file declared were `<uuid>_backend` and `<uuid>_frontend` for all previews at once — two concurrent previews would have shared them, and one could reach the other's database. Coolify's proxy was never on those networks anyway; it joins the per-preview network it creates. The stack now declares none, and check-preview-stack.ts fails if one comes back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VKWqbmrPJFv8aKZBp36uD
1 parent 64208ba commit 9bdcf81

6 files changed

Lines changed: 39 additions & 32 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
This corrects the preview stack before pull request previews reach a release, so the behaviour
5+
described in the previews release note is the behaviour that ships. Nothing an operator has already
6+
deployed changes.

docker/preview/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ hold, each enforced before Coolify is asked to deploy:
3232
network membership, or host control socket.
3333

3434
The application server runs without Linux capabilities and with `no-new-privileges`. PostgreSQL and
35-
NATS data live in PR-specific volumes. Coolify's proxy reaches the webapp and API on the frontend
36-
network; the database and NATS broker also use an internal backend network.
35+
NATS data live in PR-specific volumes. The stack declares no networks of its own: Coolify runs every
36+
preview of this application under one Compose project named after the application UUID, so a network
37+
named here would be shared by all of them at once. Each preview gets Coolify's own per-preview
38+
network instead, which its proxy joins to reach the webapp and API.
3739

3840
Deployment authority is deliberately the same as push authority;
3941
[ADR 0035](../../docs/decisions/0035-pull-request-previews-are-label-gated.md) records why, and what
@@ -48,7 +50,8 @@ works alone: the controller refuses to deploy a head that introduces changes any
4850
`docker/preview/` — compared against the default branch, so a stacked layer cannot inherit an edit
4951
from the layer below — and `ci-compose-validate.yml` renders the file on every pull request and fails if
5052
the stack gains a way out of its sandbox — a socket, a build stage, a published port, an external
51-
network, an unbounded memory limit, a non-internal backend, or a flipped integration switch.
53+
network, an unbounded memory limit, a network every preview would share, or a flipped integration
54+
switch.
5255
`scripts/check-preview-stack.ts` is the authoritative list; this paragraph is not.
5356

5457
## Host capacity

docker/preview/compose.app.yaml

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
# would arrive with sync, notifications and the agent digest requirement switched on, and with the
1414
# Docker socket mounted — ADR 0035 option 6 has the rendered evidence. scripts/check-preview-stack.ts
1515
# fails when the reference gains a variable this file neither sets nor records as omitted.
16+
#
17+
# This file declares no networks. Coolify runs every preview of this application under one Compose
18+
# project — the project name is the application UUID, with no pull request in it — so a network named
19+
# here would be `<uuid>_backend`, shared by every preview at once. Coolify instead gives each preview
20+
# its own network and attaches its proxy to that, which is what keeps the stacks apart.
1621

1722
services:
1823
postgres:
@@ -24,8 +29,6 @@ services:
2429
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
2530
volumes:
2631
- postgres-data:/var/lib/postgresql/data
27-
networks:
28-
- backend
2932
security_opt:
3033
- no-new-privileges:true
3134
cap_drop:
@@ -61,8 +64,6 @@ services:
6164
command: ["--jetstream", "--store_dir=/data", "--http_port=8222"]
6265
volumes:
6366
- nats-data:/data
64-
networks:
65-
- backend
6667
security_opt:
6768
- no-new-privileges:true
6869
cap_drop:
@@ -110,11 +111,14 @@ services:
110111
HEPHAESTUS_TRUSTED_PROXIES: ${HEPHAESTUS_TRUSTED_PROXIES}
111112
APPLICATION_HOST_URL: https://${SERVICE_FQDN_WEBAPP}
112113
HEPHAESTUS_AUTH_API_BASE_PATH: ""
113-
DATABASE_URL: postgresql://postgres:5432/hephaestus?sslmode=disable
114+
# Coolify renames each service to `<service>-pr-<id>` and publishes the renamed hosts as
115+
# SERVICE_NAME_*, so the bare service name resolves to nothing inside a preview. The defaults
116+
# keep this file runnable on its own.
117+
DATABASE_URL: postgresql://${SERVICE_NAME_POSTGRES:-postgres}:5432/hephaestus?sslmode=disable
114118
DATABASE_USERNAME: hephaestus
115119
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
116-
NATS_SERVER: nats://nats:4222
117-
HEPHAESTUS_SYNC_NATS_SERVER: nats://nats:4222
120+
NATS_SERVER: nats://${SERVICE_NAME_NATS:-nats}:4222
121+
HEPHAESTUS_SYNC_NATS_SERVER: nats://${SERVICE_NAME_NATS:-nats}:4222
118122
NATS_ENABLED: "true"
119123
NATS_DURABLE_CONSUMER_NAME: preview-consumer
120124
HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD: 24h
@@ -140,9 +144,6 @@ services:
140144
SENTRY_DSN: ""
141145
WEBHOOK_SECRET: ${WEBHOOK_SECRET}
142146
WEBHOOK_EXTERNAL_URL: ""
143-
networks:
144-
- frontend
145-
- backend
146147
depends_on:
147148
postgres:
148149
condition: service_healthy
@@ -189,8 +190,6 @@ services:
189190
SENTRY_ENVIRONMENT: preview
190191
SENTRY_DSN: ""
191192
POSTHOG_ENABLED: "false"
192-
networks:
193-
- frontend
194193
depends_on:
195194
appserver:
196195
condition: service_healthy
@@ -216,11 +215,6 @@ services:
216215
memory: 256M
217216
pids: 128
218217

219-
networks:
220-
frontend:
221-
backend:
222-
internal: true
223-
224218
volumes:
225219
postgres-data:
226220
nats-data:

docs/decisions/0035-pull-request-previews-are-label-gated.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ The gates that remain are the ones that describe *what may run*, not *who asked*
108108
this rule is load-bearing.
109109
`ci-compose-validate.yml` holds the other half: on every pull request it renders that file and fails
110110
if the stack gains a way out of its sandbox — a socket, a build stage, a published port, an external
111-
network, an unbounded memory limit, a non-internal backend, or a renamed or flipped integration
112-
switch. `scripts/check-preview-stack.ts` is the authoritative list, and its own tests prove each
111+
network, an unbounded memory limit, a network every preview would share, or a renamed or flipped
112+
integration switch. `scripts/check-preview-stack.ts` is the authoritative list, and its own tests prove each
113113
assertion fails when that escape is present.
114114
- Every service runs the image CI published for the exact head commit, and each digest must carry a
115115
build attestation signed by this repository's `reusable-docker-build.yml`, verified before Coolify

scripts/check-preview-stack.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interface Service {
2323

2424
interface Stack {
2525
services: { appserver: Service; postgres: Service; webapp?: Service };
26-
networks: { backend: { internal?: boolean } };
26+
networks: Record<string, { internal?: boolean }>;
2727
}
2828

2929
const sandboxed: Stack = {
@@ -47,7 +47,7 @@ const sandboxed: Stack = {
4747
cap_add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"],
4848
},
4949
},
50-
networks: { backend: { internal: true } },
50+
networks: {},
5151
};
5252

5353
/** Clones the good stack, then lets a test break exactly one thing. */
@@ -107,11 +107,11 @@ void describe("preview stack sandbox", () => {
107107
/no memory limit/,
108108
],
109109
[
110-
"routable backend",
110+
"a network every preview would share",
111111
(s) => {
112-
s.networks.backend.internal = false;
112+
s.networks.backend = { internal: true };
113113
},
114-
/no longer internal/,
114+
/every preview would share/,
115115
],
116116
];
117117

@@ -194,7 +194,6 @@ void describe("preview stack sandbox", () => {
194194
assert.deepEqual(findViolations({}), [
195195
"the rendered stack declares no services",
196196
"the rendered stack has no appserver service, so no switch was checked",
197-
"the backend network is gone",
198197
]);
199198
});
200199
});

scripts/check-preview-stack.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,15 @@ export function findViolations(stack: unknown): string[] {
226226
if (!services.some(([name]) => name === "appserver")) {
227227
violations.push("the rendered stack has no appserver service, so no switch was checked");
228228
}
229-
const backend = records(stack.networks).find(([name]) => name === "backend");
230-
if (!backend) violations.push("the backend network is gone");
231-
else if (backend[1].internal !== true)
232-
violations.push("the backend network is no longer internal");
229+
// Coolify runs every preview of this application under one Compose project, named after the
230+
// application UUID with no pull request in it. A network named here is therefore `<uuid>_<name>`
231+
// for all of them at once, and one preview reaches another's database over it. Coolify's own
232+
// per-preview network, which its proxy joins, is what keeps them apart. `default` is Compose's
233+
// own and carries the project name too, but nothing joins it once no service names a network.
234+
for (const [name] of records(stack.networks)) {
235+
if (name === "default") continue;
236+
violations.push(`the stack declares the ${name} network, which every preview would share`);
237+
}
233238

234239
return violations;
235240
}

0 commit comments

Comments
 (0)