feat(coolify): set up a Coolify server over SSH - #4326
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1c12a10a27
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
🔍 Dyadbot Code Review SummaryVerdict: 🤔 NOT SURE - Potential issues Reviewed all 61 changed files. The combined This is a large, carefully-built feature. The main/renderer boundary is respected (only the public half of the SSH key ever crosses, the private key and generated password stay in main), IPC inputs go through zod contracts, new channels are added to both The findings below are all MEDIUM — none block merge. Issues Summary
🟢 Low Priority Notes (8 items)
Generated by Dyadbot persona-based code review |
There was a problem hiding this comment.
All reported issues were addressed across 61 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f40dcecbe0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 07fc8d9c4f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 23 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
🔍 Dyadbot Code Review SummaryVerdict: 🤔 NOT SURE - Potential issues A large, unusually disciplined PR: 63 files, a pure state machine ( Values that reach a root shell are rejected rather than escaped ( The two issues below are robustness/UX, not correctness blockers. Note also that the context's combined Issues Summary
🟢 Low Priority Notes (7 items)
The Generated by Dyadbot persona-based code review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbd8cab38c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
🔍 Dyadbot Code Review SummaryVerdict: 🤔 NOT SURE - Potential issues A large, unusually well-tested feature: a main-process state machine for installing Coolify over SSH, a new Two medium issues below. Neither blocks merge. I reviewed the diff statically; I did not run the suite, and the Issues Summary
🟢 Low Priority Notes (7 items)
Generated by Dyadbot persona-based code review |
There was a problem hiding this comment.
All reported issues were addressed across 12 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3290fd95f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 12 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 432149885b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
🔍 Dyadbot Code Review SummaryVerdict: 🤔 NOT SURE - Potential issues Large, well-structured feature: a main-process state machine for installing Coolify over SSH, a new What holds up well:
The three items below are informational and none blocks merge. Issues Summary
Domain ownership guard. Insecure token store. The setup path writes Telemetry classification. 🟢 Low Priority Notes (6 items)
Two things I could not verify from the diff and am not asserting either way: whether the Generated by Dyadbot persona-based code review |
…a fault classify maps unreachable and timeout to External, which telemetry does not filter, so a mistyped address or a firewalled port 22 sent a PostHog $exception on every check — with whatever the user typed in the message. The same call over HTTP is already treated as the user's own network rather than a fault here; SSH was never wired into it. Only the two that say what went wrong. "unknown" is the bucket for a failure nothing here recognised, which is the kind worth hearing about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dyad invents the admin password rather than discovering it, so it is known a moment before the installer writes it into Coolify's own .env. Nothing was stored until the server reported the account back, minutes later — and anything that ended the process in between took the only copy with it, leaving a running Coolify nobody has the password for and a preflight that refuses to install over the container. It goes down before the run now. A run that ends without ever seeding an account puts back whatever stood there before, so a server that never got one leaves no password behind for it — and an account belonging to a server set up earlier, still the only copy of its own password, is not taken along with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A server reached by a name plain DNS cannot see — an /etc/hosts entry, a search-domain name — left nothing on our side to hold the domain against, and that fell through as though the domain had been checked. It had not been checked at all, which is the whole of what the function is for, and the certificate poll after it settles for any address answering with a certificate it trusts. The earlier reasoning was that refusing blocks a setup over a private name. It does, and that is the smaller loss: such a setup keeps a working plain-HTTP address and can have the domain set in Coolify by hand, where the alternative is the API token going to whatever the domain points at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saving a token by hand refuses an address that is not encrypted unless the user says otherwise. Installing a server did not: the token is written when the run ends, before the finished screen has said anything, so the warning there described something already decided. The screen asks now, unticked, and Continue takes the token and the address back off unless it was agreed to. What is left is the state a run whose token could not be minted already produces — the account, the address on screen, and the token form a paste away — so nothing new had to be invented for the answer to mean something. Only those two fields go. The admin account is the way into a server that is running either way, and holding it sends nothing anywhere; the token is what would have crossed the network in the clear on every deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…led run Putting the password down before the installer meant putting something back if no account ever appeared. What went back was a snapshot taken when the record went down — and the record goes down after connecting and after the preflight, so a run that ended before either wrote `undefined` over whatever stood there. Cancelling, a server not answering, and retrying an address that already has Coolify all end that way, and all of them threw away the password of a server set up earlier: the loss this was written to prevent, on the ordinary paths. Only a run that put a record down takes one back, and only if its own is still the one there. Minutes of installing sit in between, and signing out in another window is a newer answer than a snapshot from before the run — which rules/electron-ipc.md says in as many words about reading settings across an await. Also splits the refusal for a server whose own name does not resolve. It was borrowing the sentence for a domain that could not be looked up, and naming the domain, when the domain may be perfectly correct and the server is what nothing could be found for. And a decline that fails now keeps the finished screen up rather than dismissing over a token still stored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dable The way out told the record apart by its password, and a password that will not decrypt is dropped on the way out of readSettings with the account kept. So a keychain relocking during the install — minutes, on a screen that says leaving it does not stop the run — made the record unrecognisable: the restore was skipped, an earlier server's account went with it, and what stood in its place was a password for an account that was never created. Identified by what still says whose it is. The password when there is one, and the email and address either way, which is what the other window finishing its own install differs by. Also puts the comment about families back over the branch about families. It had been left heading the one about a server that does not resolve, where its first clause said the opposite of the condition beneath it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An account whose password will not decrypt comes back from readSettings with the key gone, not with it undefined. Writing it back that way reads to writeSettings as a key some consumer dropped, so the ciphertext on disk is handed back — and by then that ciphertext is this run's provisional password, filed under the earlier server's name and address. A credential for a machine it does not open, which is the harm the record's own description warns about. Named now, so an absent password is the clear it was meant to be. Nothing is lost that was not already: the earlier ciphertext was overwritten when the provisional record went down. Also covers the account's email in the check that tells this run's record from someone else's — it was right and nothing held it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 11 files (changes from recent commits).
Confidence score: 2/5
src/coolify_setup/https_setup.tslets a custom domain with no DNS records fall through fromno-recordstopoints-here, sotryEnableHttpsmay accept an unverified domain; keep the no-records result from being treated as pointing here.src/components/CoolifyServerSetup.tsxleaves an already-persisted token in place when setup is abandoned, allowing a relaunch to enable deployment over a plain-HTTP address; remove the token when the screen is closed without Continue.src/ipc/handlers/coolify_setup_handlers.tscan clear a newer Coolify connection and token created by another window because cleanup is not tied to this setup run; pass and validate the run-specific token or instance identity before clearing.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/ipc/handlers/coolify_setup_handlers.ts">
<violation number="1" location="src/ipc/handlers/coolify_setup_handlers.ts:138">
P2: When another window changes the Coolify connection before this setup screen is dismissed, this handler clears that newer instance and token because it has no way to identify the token belonging to this run. Pass the setup instance or token as an expected value and clear only when the current settings still match it.</violation>
</file>
<file name="src/coolify_setup/https_setup.ts">
<violation number="1" location="src/coolify_setup/https_setup.ts:265">
P1: When the SSH host name has no DNS addresses and the custom domain currently has no records, `domainCheckVerdict` returns `no-records`, so this guard falls through to `points-here`. `tryEnableHttps` can then accept a trusted certificate from an old server if DNS changes during polling; classify `no-records` as `server-unresolved` when `expectedIps` is empty.</violation>
</file>
<file name="src/components/CoolifyServerSetup.tsx">
<violation number="1" location="src/components/CoolifyServerSetup.tsx:261">
P1: When the user closes or abandons this screen before clicking Continue, the unchecked box never removes the already-persisted token. Relaunching then sees `hasToken` and can enable deployment over the plain-HTTP address without explicit acceptance; keep the token out of persistent settings until acceptance, or revoke it on every abandonment path.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d40878a61
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
🔍 Dyadbot Code Review SummaryVerdict: 🤔 NOT SURE - Potential issues Large, unusually well-documented feature: a main-process state machine that SSHes into a bare server, runs Coolify's installer, seeds an admin account, tries for HTTPS, and mints an API token, plus the panel that drives it. The IPC surface is registered through the existing typed-contract layer, both new channels are in Two things are worth a second look before this ships, both about how the freshly minted credentials are handled rather than about the install itself. Neither is a blocker. Note on the review context: the aggregate Issues Summary
🟢 Low Priority Notes (6 items)
Generated by Dyadbot persona-based code review |
🎭 Playwright Test Results❌ Some tests failed
Summary: 295 passed, 1 failed, 1 flaky, 12 skipped Failed Tests🍎 macOS
📋 Re-run Failing Tests (macOS)Copy and paste to re-run all failing spec files locally: npm run e2e \
e2e-tests/coolify_setup.spec.ts
|
…oing it The gate asked after the token was already on disk, so it only worked for someone who pressed Continue. Closing the panel, switching away, quitting, or a crash while the finished screen was up left a token stored for an address nobody had agreed to — and the next launch simply read as connected. Two reviewers called it, and it was the known cost of undoing rather than withholding. Withheld now. A run that ends on an address without a certificate keeps its token in the process, and only agreeing puts it on disk. Anything else loses it, which is the safe direction and lands on the screen a run whose token could not be minted already produces. The decline handler goes with it, and with it an unconditional settings write that would have cleared another window's connection. Also stops a domain being taken on trust when neither side resolved. domainCheckVerdict answers about the domain first, so a name with no records yet came back before our own address was ever looked at, and read as agreement having compared nothing. And hands ssh2 an address it recognises: a v6 literal typed the way documentation writes it, in brackets, was looked up as a hostname, so a reachable server reported as unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The installer's end-to-end journey installs onto a loopback address, which can never be given a certificate — so it ends on the branch that now holds the token rather than storing it, and went on asserting the sentence the secure branch says and pressing Continue without a word. It ticks the box now. That click is the whole of the difference between the server picker and the token form, which makes this the one place the agreement is proved end to end rather than in parts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30d77033f5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| */ | ||
| export function isPlausibleAdminEmail(email: string): boolean { | ||
| const trimmed = email.trim(); | ||
| if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return false; |
There was a problem hiding this comment.
Reject invalid mailbox syntax before installing
For an address such as a..b@gmail.com, this permissive regex returns true even though consecutive dots make the mailbox invalid. The run handler therefore starts the installation, but Coolify's RootUserSeeder later rejects the address after the Coolify container exists; subsequent setup attempts are then blocked by preflight as already installed. Validate the complete mailbox syntax before beginning the non-idempotent install, independently of whether the domain resolves.
Useful? React with 👍 / 👎.
| export function resetCoolifySetupStateForTests(): void { | ||
| inspectedFingerprints.clear(); | ||
| readyHosts.clear(); |
There was a problem hiding this comment.
Clear held insecure tokens during test reset
When a handler test finishes an insecure setup without accepting or dismissing it—as the first test in the insecure-address describe block currently does—heldInsecureToken survives this reset, so a later test can call accept-insecure-token and persist a credential created by the previous case. This is fresh evidence beyond the earlier controller-reset issue because the token holder was introduced later and is another process-global owner omitted here; include it in teardown or construct the handler state per test. rules/state-machines.mdL610-L611
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
1 issue found across 8 files (changes from recent commits).
Confidence score: 5/5
- In
src/ipc/handlers/coolify_setup_handlers.ts,resetCoolifySetupStateForTestsomits clearingheldInsecureToken, which can make tests order-dependent and leak a token across cases — clear that field alongside the other process-global state to keep test isolation reliable.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/ipc/handlers/coolify_setup_handlers.ts">
<violation number="1" location="src/ipc/handlers/coolify_setup_handlers.ts:83">
P3: `resetCoolifySetupStateForTests` does not clear the new held token, so tests can leak an insecure token across cases and become order-dependent. Clear `heldInsecureToken` alongside the other process-global setup state.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| * | ||
| * One at a time, like the machine itself. | ||
| */ | ||
| let heldInsecureToken: { instanceUrl: string; token: string } | null = null; |
There was a problem hiding this comment.
P3: resetCoolifySetupStateForTests does not clear the new held token, so tests can leak an insecure token across cases and become order-dependent. Clear heldInsecureToken alongside the other process-global setup state.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/ipc/handlers/coolify_setup_handlers.ts, line 83:
<comment>`resetCoolifySetupStateForTests` does not clear the new held token, so tests can leak an insecure token across cases and become order-dependent. Clear `heldInsecureToken` alongside the other process-global setup state.</comment>
<file context>
@@ -68,6 +68,20 @@ const inspectedFingerprints = new Map<string, string>();
+ *
+ * One at a time, like the machine itself.
+ */
+let heldInsecureToken: { instanceUrl: string; token: string } | null = null;
+
function broadcastState(state: SetupSnapshot) {
</file context>
| // without reaching the code below is exactly how the only copy of it | ||
| // gets lost. Put back on the way out if no account ever appeared, so a | ||
| // server that never got one does not leave a password behind for it. | ||
| onCredentialsBuilt: ({ credentials, dashboardUrl }) => { |
There was a problem hiding this comment.
🟡 MEDIUM
Second install overwrites the stored admin password for the first server
onCredentialsBuilt writes the new run's admin account into settings.coolify.admin before the installer runs, and settings hold exactly one admin record. The restore path below only fires when this run never seeded an account (provisional && !accountConfirmed), so a run that does seed one silently discards whatever account was stored for a different machine. This is reachable: if an install onto server A fails after the account was seeded (for example the dashboard poll fails because port 8000 is blocked), the state is failed with cancelled=false, which makes isReportingFailure true in CoolifyConnector, which skips the 'Dyad already set up a server' refusal card and hands the installer form back with an empty host field. Typing server B and pressing Install then destroys the only copy of server A's password, on a machine that has a running Coolify with an account nobody else knows the credentials for. Signing out asks for an explicit acknowledgement before forgetting the same record; this path asks for nothing.
💡 Suggestion: Before writing over an existing coolify.admin whose instanceUrl does not match the host being installed, either refuse or make the installer screen say plainly that continuing forgets the stored account for that other server, the way CoolifySignOutDialog does.
| <p className="text-sm text-muted-foreground"> | ||
| {result.secure | ||
| ? "Dyad created its own API token, so you can pick a server and project next." | ||
| : "Dyad created an API token for this server. It is not kept unless you say so above, because this address is not encrypted."} |
There was a problem hiding this comment.
🟡 MEDIUM
Declining an insecure token leaves no way to obtain one
When a run ends on a plain-HTTP address with a token minted (result.tokenStored true, result.secure false), leaving the checkbox unticked and pressing Continue calls leaveResult with acceptToken false, so dismiss() drops heldInsecureToken and the minted token is gone. onUseExisting then puts the user on the token form, pinned read-only to that same unencrypted address, where saving requires ticking 'Connect anyway' - the consent they just declined. The coolify-setup-manual-token block, which is the only place that explains how to enable the API and create a token by hand in Coolify, is rendered only when tokenStored is false, so it is hidden in exactly this case. The user is left on a form asking for a token they were never shown and are given no instructions for producing.
💡 Suggestion: Show the manual-token guidance (or a pointer to adding a domain so HTTPS can be retried) when the user continues without accepting the insecure token, rather than only when no token could be minted.
| * On the ordinary path it is stored instead, and read back through | ||
| * revealCredentials — a password shown once is a password nobody can use. | ||
| */ | ||
| adminPassword: z.string(), |
There was a problem hiding this comment.
🟡 MEDIUM
Generated admin password rides in the broadcast setup snapshot
SetupResultSchema.adminPassword is part of the done snapshot, which broadcastState pushes to every BrowserWindow over coolify-setup:changed and which any renderer can pull with coolify-setup:snapshot. The plaintext password therefore sits in the React Query cache of every window, including ones not showing the panel, for as long as the machine rests in done. The run and revealCredentials contracts and handlers both carry a 'DO NOT LOG this handler' marker because they carry this secret; snapshot and changed carry the identical payload and carry no such marker, so a maintainer switching snapshot to createLoggedTypedHandler would log the password without any warning in the file.
💡 Suggestion: Mark the snapshot contract and the changed event with the same DO NOT LOG note as run and revealCredentials, and consider having the done screen read the password through revealCredentials rather than shipping it in a state that is pushed to every window.
🔍 Dyadbot Code Review SummaryVerdict: 🤔 NOT SURE - Potential issues Large, unusually well-documented feature: a state machine in the main process, a new Nothing I found blocks merge. The three items below are worth a look; two are about what happens to a password only Dyad holds, and one is a dead-end in the unencrypted-address flow. Note on confidence: the context's aggregate Issues Summary
🟢 Low Priority Notes (8 items)
Generated by Dyadbot persona-based code review |
Dyad can already deploy to an existing Coolify instance. This adds the step before it: pointing Dyad at a bare Linux server and getting a working, signed-in Coolify onto it.
The user provides an address, an email, and optionally a domain they own. Dyad shows a public key to install on the server, then connects, checks the machine, runs Coolify's installer, waits for the dashboard, ensures an admin account exists, tries to put the instance on HTTPS, and mints an API token for the existing deploy flow. A failure reports what the server said rather than an exit code.
Without a domain, HTTPS goes through sslip.io. With one, Dyad checks it resolves to the server before applying it, since Coolify will not issue a certificate for a name that does not point at it. An address that cannot have a certificate at all — loopback, private, or IPv6 — finishes on plain HTTP and says so. A Coolify too old to mint a token finishes too, handing over the sign-in details instead.
Several setup steps drive Coolify's internals rather than a supported interface, because no supported interface exists. Coolify has no way to enable API access, mint a token, create or find the first user, set the instance domain, or state its version before its API is reachable — so each of those runs a short PHP script through
php artisan tinkerin the Coolify container. This is the least durable part of the PR: it depends on model and config names that Coolify is free to change. Every one of these call sites is marked WORKAROUND with a TODO naming what an official API would replace, and the hope is to delete them as Coolify grows real support.The setup runs as a state machine in the main process, per rules/state-machines.md, so an install survives leaving the panel. Covered by unit tests, integration tests driving the real flow against a real ssh2 server, and two Playwright tests.
This PR adds
ssh2(^1.17.0) as a runtime dependency of the desktop app, along with@types/ssh2as a dev dependency. It is the only new runtime dependency, and it holds the private key and sees the admin password, so it is worth a deliberate look.Why a library rather than shelling out to
ssh:sshbinary exists, is on PATH, and behaves the same on Windows, macOS and Linux.On supply chain:
ssh2is long established, pure JavaScript at its core, with two small runtime dependencies (asn1,bcrypt-pbkdf). Its native pieces (cpu-features,nan) are optional and installs proceed without them.package-lock.jsonpins 1.17.0 with a sha512 integrity hash, and CI installs from the lockfile. The caret matters only on a deliberate update.That is not a guarantee. If the dependency ever has to go, every SSH call goes through src/ipc/utils/ssh_client.ts behind
connectSsh,runandend, so reimplementing it over the systemsshbinary would not touch the flow, the state machine, or the UI.Not included: IPv6 addresses install but get no certificate; registering further servers from inside Dyad; setting a wildcard domain on the server, so deployed apps get names under it instead of sslip.io addresses — Dyad already reads one when Coolify has it configured.