The Installer serves its own single-page app and its API from the same container and the same origin:
http://<host>:<NEST_PORT>/
NEST_PORT defaults to 8000. Every route except /api/* is served by the app, so opening the bare
host address is enough — there is no separate port for the UI and no /ui path.
There is no default account, no seeded credential, no generated password, no environment variable and no CLI command that creates the Installer's first user. The first administrator is created by a human, in the browser, on the first visit.
That visit is self-registering, and only while the Installer has no users at all:
GET /api/user/exists (a public endpoint) before it draws anything.POST /api/user/register/initial, a public endpoint, with
{"email": "...", "password": "..."}. The new account is created with the role admin and the
response returns the created user.The endpoint refuses to work a second time. Once any user exists it answers HTTP 403 with
"Initial registration is not allowed once a user exists.", and the app switches to the login form
permanently, with the hint "Admin user already exists. Contact your team to set up your account."
If somebody else reaches the UI before you do, they become the administrative account. Complete the first login before the host is reachable from anywhere but your own management path.
Choose the password deliberately — the initial registration enforces a policy:
| Rule | Value |
|---|---|
| Minimum length | 8 characters |
| Uppercase | at least 1 |
| Lowercase | at least 1 |
| Digits | at least 1 |
| Symbols | at least 1 |
Failing validation returns: "Password must be at least 8 characters and contain uppercase, lowercase, number, and special character."
Verify the account exists (no authentication needed):
curl -sS http://localhost:8000/api/user/exists
Once the account is created this reports true, and the registration form will never appear again.
Use the login form, or call the API directly:
curl -sS -X POST http://localhost:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"..."}'The email is matched case-insensitively — it is lower-cased before lookup. The responses you may see:
| Situation | Response |
|---|---|
| Wrong email or password | HTTP 401, Invalid email or password — the same message for both, so an attacker cannot tell whether an address exists |
| Correct credentials, but the account was disabled | HTTP 403, Account is deactivated |
| Success | HTTP 200 with data.user (id, email, role), data.accessToken and data.expiresIn |
| Do this | Why |
|---|---|
| Change the administrator password (account menu → change password) if it was typed by anybody else, transmitted over a channel you do not control, or reused from another system | The first account is the whole control plane. POST /api/user/:id/change-password needs the current password, rejects reusing the same one (You can't set the same password), and revokes every refresh token the user holds |
| Re-enter the Azure client secret if a third party supplied it | It is stored encrypted in the Installer's database and is what allows image pulls and artifact downloads. Changing it is PUT /api/system-secrets/azure |
| Create one named account per administrator and use no shared logins | The deployment audit trail resolves the acting user; a shared account destroys the attribution |
| Put an authentication gate in front of the UI extra to the product if the host is reachable beyond your management network | The Installer ships no second factor — no TOTP, no WebAuthn, no OIDC |
Leave SWAGGER_ENABLED=false | Swagger publishes the entire API surface at /api/docs |
Changing a password and resetting one are different operations, and the Installer has no
self-service password recovery: there is no email flow and no "forgot password" link. If the only
administrator loses their password, another admin must reset it with
POST /api/user/:id/reset-password. Keep more than one administrator for this reason alone. See
Who should have access.
Each sign-in issues two JSON Web Tokens, signed with the JWT_SECRET that the backend generated into
its secrets volume on first boot. Their lifetimes come from .env:
| Setting | Default | Effect |
|---|---|---|
JWT_ACCESS_EXPIRY | 30m | Life of the access token, in the format the ms library accepts (30m, 1h, 900s) |
JWT_REFRESH_EXPIRY | 7d | Life of the refresh token |
The access token carries type: "access" and the refresh token type: "refresh"; the backend rejects
a refresh token presented as an access token. If the variables are unset, the code falls back to 1h
for the access token and 7d for the refresh token.
Where the tokens live. The login response both sets cookies and returns the access token in the body. The cookies are the durable mechanism:
| Cookie | Path | Notes |
|---|---|---|
delamain_access_token | /api | httpOnly, SameSite=Strict by default |
delamain_refresh_token | /api/auth | httpOnly, single-use |
Both are marked Secure when the request itself is HTTPS, or when a proxy sets x-forwarded-proto: https. Both are SameSite=Lax only for a cross-origin call from localhost, and SameSite=None plus
Partitioned only when a cross-origin call arrives over HTTPS. When the browser host matches the
request host, no cookie domain is set; in production, when they differ, the cookie is scoped to the
last two labels of the host.
Besides the cookies, the browser app stores the access token in localStorage under
manager:access-token, and the user object under manager:auth-user. Any script running in the
page origin can read those values. Keep the UI on a trusted management network and do not add third
party scripts to it.
Rotation and invalidation. The refresh token is single-use: POST /api/auth/refresh revokes the
presented token and issues a new pair. A fresh sign-in invalidates all of that user's outstanding
refresh tokens, as does a password change or an administrative password reset. POST /api/auth/logout
revokes the presented refresh token and clears both cookies; it returns HTTP 204.
Expired refresh tokens are removed by a scheduled cleanup task, so the table does not grow without bound.
Consequences worth planning for:
JWT_SECRET, or deleting the delamain_secrets volume that holds it, invalidates every
session on the next restart.Next: Post-install checklist.