F5.1 — One-Line Server Installer
Automated, idempotent installation of Vexlyx onto a fresh Ubuntu 24.04 server via:
curl -fsSL https://vexlyx.atlantiqs.org/install.sh | bashWhat it does
- Installs Docker Engine + Compose, Node.js 22 + pnpm, Python 3 +
cryptography, and Nixpacks. (Node 22, not 20 — pnpm 11, pinned inpackage.json'spackageManagerfield, uses thenode:sqlitebuilt-in and fails withERR_UNKNOWN_BUILTIN_MODULEon Node 20.) - Collects the panel domain, admin credentials, and mail hostname (env vars, interactive prompt, or safe generated defaults — see Configuration).
- Generates and persists application secrets exactly once.
- Builds the panel on the host (
pnpm install && pnpm build && prisma generate) and builds the dashboard/api Docker images. - Brings up Postgres/MySQL/Redis, runs Prisma migrations, creates the admin user.
- Brings up Traefik (requesting a real Let's Encrypt certificate for the panel domain), CoreDNS, Postfix, Dovecot, Roundcube, and the panel itself.
- Configures UFW.
Every step checks real state before acting (file exists? container running? rule present?), so re-running install.sh at any point — including after a failure — is always safe and cheap for whatever already succeeded.
Architecture
install.sh — public entrypoint (this is what curl fetches)
└─ clones/updates the repo into /opt/vexlyx, then execs:
system/scripts/install/run.sh — orchestrator
├─ lib.sh — logging, root check, secrets-file helpers
├─ config.sh — collects config, generates secrets
└─ steps/01-preflight.sh … 16-summary.sh — one file per concern, run in orderinstall.sh is deliberately thin: it only figures out where the Vexlyx checkout lives and hands off. All real logic is versioned inside the repo under system/scripts/install/, so it can be reviewed, tested, and changed like any other code — and a curl | bash install always runs whatever the pinned VEXLYX_REPO_REF branch/tag actually contains.
Why the API container is different from the dashboard container
The dashboard is a stateless frontend — apps/dashboard/Dockerfile is a conventional multi-stage turbo prune + Next.js standalone build, fully self-contained.
The API is not. Every deploy/build/mail/DNS/SSL/database feature works by spawning system/python/*.py as a child process (spawn("python", [scriptPath])), which in turn shells out to the host's docker CLI — docker_manager.py runs docker compose up -d for each user project, postfix_manager.py runs docker exec vexlyx-postfix ..., ssl_manager.py reads/writes docker/traefik/certs, etc. (This is deliberate — see CLAUDE.md §6: routes must never call Docker directly, only through the Python layer.)
Containerizing the API without accounting for that coupling would silently break nearly every feature. Instead, apps/api/Dockerfile builds a toolchain-only image (Node, Python, git, Nixpacks, and a Docker CLI client) with no application code baked in. At runtime, docker-compose.prod.yml bind-mounts:
- the entire
/opt/vexlyxcheckout (already built on the host by step 09) at the same absolute path inside the container, and /var/run/docker.sock
This "Docker-outside-of-Docker" setup means every relative path the Python managers resolve (Path.cwd().parent.parent / "docker" / "traefik" / "certs", script_dir.parent / "templates" / "docker-compose", etc.) and every path in a docker-compose.yml the API generates for a user project, resolves identically whether read from inside the API container or by the host daemon it's talking to. It also means the API container's Python subprocess model is unchanged from how it already works today — Docker access is still mediated entirely through system/python/*.py, just running one layer further down.
Compose overlay
Production only ever changes docker-compose.yml (the dev environment, untouched) via an overlay:
docker compose --env-file /etc/vexlyx/vexlyx.env \
-f docker-compose.yml -f docker-compose.prod.yml up -ddocker-compose.prod.yml uses the Compose Spec's !override tag to fully replace (not append to) a base service's ports/volumes list — e.g. Traefik's dev-only insecure dashboard port and Postgres/MySQL/Redis's host-published ports are removed in prod, and CoreDNS is bound to 127.0.0.1 only (it forwards unmatched queries to public resolvers, so publishing it on 0.0.0.0 would stand up an open recursive resolver). adminer gets a profiles: ["debug"] gate so a plain up -d never starts it. See the Compose Spec merge rules if you need to add another overridden list.
Configuration
Every value can be set as an environment variable ahead of time (for scripted/CI installs); if unset and a terminal is attached, you're prompted; if unset and non-interactive, a safe default is generated where one exists (never for the domain — see below).
| Variable | Required | Default |
|---|---|---|
VEXLYX_DOMAIN | Yes | none — the installer exits with instructions if unset and non-interactive |
VEXLYX_BASE_DOMAIN | No | $VEXLYX_DOMAIN — set separately if deployed-project subdomains should live in a different zone than the panel (F5.9) |
VEXLYX_ADMIN_EMAIL | No | admin@$VEXLYX_DOMAIN |
VEXLYX_ADMIN_PASSWORD | No | randomly generated, printed once at the end |
VEXLYX_MAIL_HOSTNAME | No | mail.$VEXLYX_DOMAIN |
VEXLYX_MAIL_DOMAIN | No | $VEXLYX_DOMAIN |
VEXLYX_ENABLE_PUBLIC_DNS | No | false — leaves port 53 closed in UFW and bound to loopback |
VEXLYX_HOME | No | /opt/vexlyx |
VEXLYX_REPO_URL / VEXLYX_REPO_REF | No | the Vexlyx repo / main |
The API also receives COOKIE_DOMAIN=$VEXLYX_DOMAIN (set directly in docker-compose.prod.yml, not user-configurable) — see Cross-subdomain session cookie below.
VEXLYX_PUBLIC_IP is not a config prompt — the installer auto-detects it on every run (external IP-echo services, falling back to the local route's source address) and persists it to /etc/vexlyx/vexlyx.env, from where it flows into the api container as PUBLIC_IP for DNS-onboarding guidance (F5.9, see docs/dev/dns-onboarding.md). Set it manually in that file if detection fails (e.g. an offline install).
Secrets
/etc/vexlyx/vexlyx.env (mode 0600) holds SESSION_SECRET, ENCRYPTION_KEY, POSTGRES_PASSWORD, MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD, plus the collected domain/admin-email/mail-hostname config. It's generated exactly once — re-running the installer never rotates it, so a re-run can't invalidate existing sessions or encrypted data. It lives outside the git checkout specifically so an upgrade (git pull) never touches it. The admin password is not persisted anywhere — it's applied to the database (Argon2id-hashed) and shown once in the final summary.
Cross-subdomain session cookie
The dashboard ($VEXLYX_DOMAIN) and API (api.$VEXLYX_DOMAIN) are separate origins. createSession() in apps/api/src/plugins/auth.ts sets the vexlyx_session cookie with an explicit Domain attribute (COOKIE_DOMAIN env var, set to $VEXLYX_DOMAIN) specifically so both subdomains can read it — without it, the cookie defaults to host-only scope on whichever origin set it, and the dashboard's server-side auth guards ((panel)/layout.tsx, (standalone)/layout.tsx, which fetch /api/auth/me forwarding the cookie manually) never see it, redirecting to /login even when the user just successfully logged in. destroySession()'s clearCookie call uses the same domain so logout actually clears the right cookie instead of setting an unrelated host-only one. Unset for local dev, where dashboard and API are both plain localhost.
Extending
To add a new step, drop a numbered file in system/scripts/install/steps/ (it's sourced into the same shell as everything else, so lib.sh/config.sh helpers and prior steps' exported variables are already in scope) and renumber the banner counts (grep -rn "log_step.*\[.*\/16\]") if you're inserting rather than appending.
Security notes
- The api container runs as root. It needs
/var/run/docker.sockfor Docker-outside-of-Docker, and socket access is already root-equivalent on the host (anyone who can reach it candocker run --privileged) — restricting the container's internal UID would add configuration complexity (matching the host's docker.sock group GID) without a real additional security boundary. The dashboard container has no such requirement and runs as an unprivileged user. - Interactive prompts only work when stdin is a real TTY. A plain
curl -fsSL https://vexlyx.atlantiqs.org/install.sh | bashhas no TTY on stdin (bash is reading the script itself from that pipe), soVEXLYX_ADMIN_PASSWORDand friends fall back to generated defaults there by design — this is deliberate, not a bug. To be prompted interactively, download the script first:curl -fsSL https://vexlyx.atlantiqs.org/install.sh -o install.sh && sudo bash install.sh. - The admin password is never persisted — only its Argon2id hash reaches the database; it's shown once in the final summary if auto-generated.
- UFW rules are added before the firewall is enabled, and the SSH rule is verified present immediately before
ufw --force enable— a detection failure aborts rather than risking a lockout. adminer(an unauthenticated raw DB browser, fine for dev) is gated behind--profile debugin the prod overlay so a plainup -dnever starts it.- Step 09 refuses to build if
apps/api/.envexists. Prisma Client auto-loads a.envfrom its working directory at runtime, regardless ofNODE_ENVor how the process was actually started — a leftover dev.env(e.g. from a manualpnpm devrun predating the installer) withVEXLYX_MOCK_DNS=truewas found in the wild to silently defeat real DNS-based domain verification (domains/service.ts'sisMockTestcheck readsprocess.env.VEXLYX_MOCK_DNSdirectly) even with Docker Compose correctly settingNODE_ENV=production. Nothing in the production stack is supposed to use a.envfile at all — config comes entirely throughenvironment:blocks — so the installer treats one existing as untrusted state and stops rather than building on top of it.
Testing
Validated by syntax-checking every script (bash -n) and by a real end-to-end install on a fresh Ubuntu (24.04-class) EC2 instance through to a working login and a deployed WordPress project. The full checklist:
- Fresh VM →
VEXLYX_DOMAIN=... VEXLYX_ADMIN_EMAIL=... bash install.shcompletes without errors — ✅ (after the fixes below) - Re-running the same command → no errors, no duplicate UFW rules/secrets/containers — ✅
- Visit
https://$VEXLYX_DOMAIN→ dashboard login page loads over a real Let's Encrypt cert — ✅ - Log in with the admin credentials → lands on and stays on
/dashboard— ✅ docker compose -f docker-compose.yml -f docker-compose.prod.yml ps→ all services healthy — ✅ufw status→ exactly the expected allow rules, SSH never blocked — ✅- Create a project, one-click install WordPress, add a custom domain → all functional — ✅
Bugs found during the real install, and their fixes
Every one of these is already fixed in the current scripts/Dockerfiles — listed here so the reasoning isn't lost and so a future regression is recognizable.
Installer / build pipeline:
- Node 20 → 22. pnpm 11 (pinned in
package.json) uses thenode:sqlitebuilt-in, added in Node 22 — failed withERR_UNKNOWN_BUILTIN_MODULEon Node 20. Fixed in step 04 (NodeSourcesetup_22.x), both Dockerfiles' base images, and rootengines.node. config.shunbound variable underset -u. The admin-password prompt block referenced${VEXLYX_ADMIN_PASSWORD}without a:-default on one line; when stdin isn't a TTY (any non-interactivessh host 'command'invocation), the variable is genuinely unset andset -uaborts. Fixed by adding the missing:-.@vexlyx/shared/@vexlyx/apibuild race. Turbo'sdependsOn: ["^build"]didn't reliably serialize a fresh checkout's build graph —tscforapi/dashboardstarted beforesharedhad writtendist/, failing withCannot find module '@vexlyx/shared'. Fixed by buildingpackages/sharedexplicitly before the fullpnpm buildin step 09.- Stale
tsconfig.tsbuildinfofrom an interrupted prior attempt.tsc --buildtrusts its incremental state file even when thedist/it refers to no longer exists, silently skipping emit. Fixed by wipingdist/tsconfig.tsbuildinfofor the TS composite projects at the start of every build (step 09) — recovery from a failed run can never depend on what debris that run left behind. - Pre-existing ESLint errors in F2.8's file-manager UI (unused imports, unnecessary regex escapes) — unrelated to F5.1, but
next buildruns ESLint strictly and nothing had ever exercised a real production build before. Fixed in the source files directly. turbo prune --dockerdoesn't copy the monorepo roottsconfig.json.packages/shared/tsconfig.json'sextends: "../../tsconfig.json"silently failed to resolve inside the pruned Docker build context, sotscfell back to an ES5-ish default lib and failed onString.prototype.startsWith/includes. Fixed by explicitlyCOPY-ing the roottsconfig.jsonintoapps/dashboard/Dockerfile's builder stage.apps/dashboard/public/never existed. The runtime stage'sCOPY --from=builder .../public ...fails hard on a nonexistent source. Fixed by adding the directory (with a.gitkeep) to the repo.
Runtime / networking:
ufw show addedvsufw statusfor the pre-enable SSH check.ufw statusonly lists rules once the firewall is active — checking it beforeufw --force enable(deliberately, to avoid a lockout) always showed no rules even though they were really added. Fixed by usingufw show added, which lists configured rules regardless of active state.- Traefik v3.4 incompatible with Docker 29+. Docker 29 raised its minimum accepted API version; Traefik ≤3.5's Docker provider hardcodes API 1.24 for its client (ignoring
DOCKER_API_VERSIONentirely) and gets rejected outright, so it never syncs container labels and never creates any routers — every request 404s. Fixed by bumping the pinned image totraefik:v3.6(which added API auto-negotiation) indocker-compose.yml. acme.jsonauto-created at644. It's gitignored, so a fresh checkout doesn't have it; Docker Compose bind-mounting a missing file auto-creates it as root:root644, and Traefik refuses to use an ACME storage file that isn't exactly600, silently disabling theletsencryptresolver for every router. Fixed by having step 07touch+chmod 600it before services come up.- Missing
python→python3symlink inside the API image. Several call sites (apps/api/src/modules/{build,deploy,wordpress,dockerfile,databases}/service.ts) spawn Python as literallypython; Debian ships no such binary. Host-side installer already handled this (step 04) but the API container's own image didn't. Fixed by adding the same symlink toapps/api/Dockerfile. - Missing
docker-buildx-plugininside the API image. Modern Docker defaultsdocker buildto BuildKit, which Nixpacks' build step invokes from inside the API container — failed with "BuildKit is enabled but the buildx component is missing or broken". Fixed by addingdocker-buildx-pluginalongsidedocker-ce-cli/docker-compose-plugininapps/api/Dockerfile. - Session cookie not shared across the dashboard/API subdomains. See Cross-subdomain session cookie — fixed with the new
COOKIE_DOMAINenv var. - Stray
apps/api/.envsilently defeating domain verification. See the security note above — fixed by having step 09 refuse to build if the file exists.
Pre-existing application bugs, unrelated to F5.1 itself, surfaced by finally running this stack for real:
system/python/build_manager.pyhad a JS-stylenullin a type annotation (str | nullinstead ofstr | None) — Python evaluates annotations at function-definition time, so this crashed the script's very first import withNameError, breaking every project build and one-click install. Fixed in place.EnvVarEditor.tsx's "Add Variable" form had noautoCompleteattributes, so Chrome's saved-login heuristics offered to autofill the site's own admin credentials into an unrelated Key/Value pair. Fixed withautoComplete="off"/"new-password".