After installation — first login, hardening, operations
Read this after any install guide. It covers first login, provisioning your first admin, per-IdP OIDC setup, break-glass, the critical database-hardening step, backups, upgrades, and troubleshooting.
- First login
- Bootstrapping your first admin
- OIDC provider setup
- Break-glass emergency access
- Database hardening (RLS)
- Production hardening checklist
- Backups and restore
- Upgrades and rollback
- Troubleshooting
Authentication methods
USCP is passwordless. The default local login is an emailed one-time code — you enter your email, a 6-digit code is emailed, and that signs you in. There is no password. An admin can change the policy any time under Access control → Login methods:
| Method | What it is | Requires |
|---|---|---|
| Email one-time code (default) | Enter email → a 6-digit code is emailed (10-min, single-use, attempt-limited) → signed in. No password. | A working SMTP relay (USCP_SMTP_HOST) — verified at install time. |
| Authenticator (TOTP) | Enter username + the 6-digit code from Google/Microsoft Authenticator, 1Password, Authy… No password, no SMTP. | Nothing external — the SMTP-free fallback. |
| SSO (OIDC / SAML) | Corporate identity provider + passkey (WebAuthn). | A registered IdP app. |
At deploy time the installer verifies SMTP actually works before proceeding; if it can't, it lets you fall back to SSO or authenticator (TOTP) instead — you're never blocked.
First login
- Browse to
https://<your-domain>/auth/login. - Email code (default): enter your admin email (the one you set as
USCP_ADMIN_EMAIL) →
Continue → a code is emailed → enter it → you're in. Authenticator: enter your username (default admin) → enter the 6-digit code from the otpauth://… secret the installer printed. SSO: use Continue with SSO.
- You land as an admin already — no SQL, no role grant (roles are managed in Access control
thereafter).
What did the installer set? The chosen method is in the final summary and in
/etc/uscp/controlplane.env(USCP_LOGIN_METHOD, plusUSCP_SMTP_*for email orUSCP_LOCAL_ADMIN_TOTP_SECRETfor authenticator, mode0640). To change methods later, use Access control → Login methods (at least one method must stay enabled).
Bootstrapping your first admin
In most installs this is automatic — you can skip this section. If you installed with USCP_ADMIN_EMAIL set (or with an USCP_ACME_EMAIL, which the installer uses as the default admin), then that email is granted the admin role automatically on its first SSO login — no SQL, nothing to do. Just sign in and you're an administrator. (The installer prints First admin: <email> will be granted admin on first SSO login during preflight to confirm.)
You only need the manual step below if you installed without an admin email set, or the first admin's SSO email differs from what you configured. To fix it without reinstalling, either:
- Set it and restart (still no SQL): add
USCP_BOOTSTRAP_ADMIN=<admin-email>to
/etc/uscp/controlplane.env and sudo systemctl restart controlplane, then log in — the match happens on first provisioning of that user. (If that user already logged in once, use the SQL fallback below, since the role is only assigned at first sight.)
- Or grant directly in the database (the classic fallback), once, after your first login has
created your user row:
Connect to Postgres as the database owner and run:
-- Promote the sole user in the bootstrap tenant to admin.
-- RLS requires the admin GUC to write across the tenant boundary from a DB session.
SET app.admin = 'on';
INSERT INTO role_assignments (tenant_id, user_id, role)
SELECT u.tenant_id, u.id, 'admin'
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE t.slug = 'prod' -- your USCP_TENANT_SLUG
ON CONFLICT DO NOTHING;
If more than one person has already logged in, scope to your own account by oidc_subject (email is stored encrypted, so match on the subject claim, not the address):
... AND u.oidc_subject = '<your-idp-subject-claim>'
You can find your subject at GET /v1/me after logging in. After the grant, log out and back in — a role change bumps your session epoch and forces re-authentication within ~60 seconds. You now have the admin role and can manage all other users and roles under Access control in the console.
For the docker DB mode, open a psql shell with:
sudo docker exec -it uscp-postgres psql -U postgres -d uscp
For native/external, use psql "$DATABASE_URL".
OIDC provider setup
Whatever the provider, the redirect URI is always:
https://<your-domain>/auth/callback
and you collect an issuer URL, a client ID, and a client secret to pass as USCP_OIDC_ISSUER, USCP_OIDC_CLIENT_ID, USCP_OIDC_CLIENT_SECRET.
Google OIDC
- Google Cloud Console → *APIs & Services → Credentials → Create Credentials → OAuth client
ID*.
- Application type: Web application.
- Authorized redirect URIs:
https://<your-domain>/auth/callback. - Copy the Client ID and Client secret.
- Issuer:
https://accounts.google.com.
Microsoft Entra ID (Azure AD)
- Entra admin center → App registrations → New registration.
- Redirect URI (platform Web):
https://<your-domain>/auth/callback. - Certificates & secrets → New client secret → copy the Value (not the ID).
- Client ID = Application (client) ID.
- Issuer:
https://login.microsoftonline.com/<tenant-id>/v2.0(use/v2.0).
Okta
- Okta admin → Applications → Create App Integration → OIDC → Web Application.
- Sign-in redirect URI:
https://<your-domain>/auth/callback. - Copy Client ID and Client secret.
- Issuer:
https://<your-org>.okta.com(or your custom auth-server issuer).
Keycloak (self-hosted / air-gapped)
- Import the ready-made realm at
deploy/keycloak/realm-export.json, or create a realm. - Clients → Create → a confidential client with Valid redirect URI
https://<your-domain>/auth/callback and client authentication on.
- Copy the Client ID and the client secret from the Credentials tab.
- Issuer:
https://<keycloak-host>/realms/<realm>.
Whatever the IdP, if login fails with a redirect/
invalid_redirect_urierror, the registered redirect URI does not exactly matchhttps://<your-domain>/auth/callback(scheme, host, path, trailing slash all matter).
Break-glass emergency access
USCP has an IdP-independent emergency admin path (M-of-N sealed recovery codes) so you are not locked out when the IdP is unreachable. Provision it now, while SSO works — you cannot create the codes once you are locked out.
As an admin of the bootstrap tenant, provision the codes (via the console under Access control → Break-glass, or the API):
curl -fsS -X POST https://<your-domain>/admin/break-glass/setup \
-H "Authorization: Bearer $YOUR_SESSION_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"threshold":2,"ttl_minutes":60}'
- Returns 10 plaintext recovery codes once — store them split among trusted custodians
(only hashes are kept server-side). Re-running replaces the set.
- Threshold (default 2) = how many distinct codes must be presented together (dual-control).
- Activation later (
POST /admin/break-glass) with a valid quorum mints a time-boxed
(default 60-minute) emergency admin session and raises a SEV-1 break-glass event — every activation is loudly audited.
Database hardening (RLS)
This is the single most important production step. USCP isolates tenants using PostgreSQL FORCE ROW LEVEL SECURITY. But a PostgreSQL superuser bypasses RLS entirely — so if DATABASE_URL points at a superuser account, per-tenant isolation is silently not enforced.
The default docker DB mode and managed databases (RDS master user, Azure server admin, Cloud SQL postgres) all hand you a superuser/admin account. Do not run USCP as that account in production. Create a dedicated non-superuser role and grant it only what it needs:
-- as the DB owner/admin, once:
CREATE ROLE uscp_app LOGIN PASSWORD 'STRONG_PASSWORD' NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE uscp OWNER uscp_app; -- or grant on an existing DB
-- The app runs migrations itself; uscp_app owns its tables so FORCE RLS applies to it.
Then set DATABASE_URL=postgres://uscp_app:STRONG_PASSWORD@host:5432/uscp?sslmode=require.
Verify isolation is real (a quick check): connect as uscp_app, SET app.current_tenant to one tenant's id, and confirm you cannot select another tenant's rows. If you can, you are still connected as a superuser — fix the role.
Also:
- Use TLS to the database:
sslmode=require, orverify-fullwith the provider CA for the
strongest guarantee.
- Restrict network access to 5432 to the app host/subnet only.
Production hardening checklist
From the platform's own hardening guide — confirm each before go-live:
- [ ] PostgreSQL with TLS (
sslmode=require/verify-full) and a non-superuser role (above). - [ ] All secrets injected from vault/KMS/Secrets — never plaintext in images. On VMs they live
only in /etc/uscp/controlplane.env (0640, owned appropriately).
- [ ] TLS terminated in front; only 443 exposed publicly;
USCP_PUBLIC_URLis the https URL.
8080 and 5432 are never public.
- [ ] SSO + passkey MFA enforced for every human (already enforced — no password auth).
- [ ]
cosign verifypassed for the image/binary/appliance before running it. - [ ] Backups scheduled and a restore drill rehearsed (below).
- [ ] Resource requests/limits set (Helm preset: 0.5–2 vCPU, 512 MB–1.5 GB).
- [ ] Audit-chain verification wired into monitoring:
GET /v1/_audit/verify(expect
chain_valid: true).
- [ ] Break-glass recovery codes provisioned and custodied.
- [ ] Firewall limited to 80/443 (self-terminated TLS) or 8080-from-LB-only (fronted).
- [ ] Time sync (chrony/NTP) active — token and license validation are time-sensitive.
Health surfaces to monitor:
curl -fsS https://<domain>/healthz # liveness
curl -fsS https://<domain>/readyz # readiness (DB + license)
curl -s https://<domain>/.well-known/api-capabilities | jq .capability_count
curl -fsS https://<domain>/v1/_audit/verify -H "Authorization: Bearer $TOKEN"
Prometheus metrics are at /metrics; on Kubernetes pods carry prometheus.io/scrape annotations automatically.
Backups and restore
All state is in PostgreSQL — the binary is stateless. Back up the database and you can rebuild everything. The audit log, SLA reports, and metering ledger are hash-chained, so integrity is re-verifiable after a restore.
Scripted backup (VM)
The repo ships deploy/backup/pg-backup.sh (custom-format pg_dump -Fc, retention pruning, optional S3, writes a .sha256 integrity marker):
DATABASE_URL='postgres://uscp_app:...@host:5432/uscp?sslmode=require' \
BACKUP_DIR=/var/backups/uscp \
BACKUP_RETENTION_DAYS=14 \
BACKUP_S3_URI='s3://my-bucket/uscp' \ # optional; needs awscli
./deploy/backup/pg-backup.sh
Schedule it from cron/systemd-timer nightly.
Restore (DR drill)
deploy/backup/pg-restore.sh verifies the .sha256 marker, then restores:
DATABASE_URL='postgres://uscp_app:...@host:5432/uscp?sslmode=require' \
./deploy/backup/pg-restore.sh /var/backups/uscp/uscp-<stamp>.dump
After restore, re-verify chains: GET /v1/_audit/verify and GET /v1/sla/reports (chain_valid: true).
Kubernetes backup CronJob
Enable the built-in CronJob (see kubernetes.md):
helm upgrade uscp deploy/helm/uscp -n uscp --reuse-values \
--set backup.enabled=true --set backup.retentionDays=14 \
--set backup.s3Uri='s3://my-bucket/uscp-backups'
Upgrades and rollback
Migrations follow expand-contract (forward+backward compatible for one release), so a one-version rollback is safe. Always back up first.
| Deployment | Upgrade | Rollback |
|---|---|---|
| VM (install.sh) | Pull new code / new USCP_BINARY, re-run deploy/install.sh (idempotent; preserves USCP_DATA_KEY), systemctl restart controlplane | Reinstall the previous binary + restart |
| Container | docker pull …:<NEW> then re-run | Re-run with the previous tag |
| Kubernetes | helm -n uscp upgrade uscp deploy/helm/uscp --set image.tag=<NEW> (rolling, zero-downtime) | helm -n uscp rollback uscp |
| VM appliance | in-VM updatectl check → signed delta update | per-module rollback |
| Air-gap | Transfer + verify a new signed binary, replace, restart | Restore the previous signed binary |
Re-running install.sh preserves the generated USCP_DATA_KEY and license, so at-rest secrets keep decrypting across upgrades. Never regenerate the data key on an existing install with data.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/readyz 503, /healthz 200 | DB or license not ready | Check logs; confirm DATABASE_URL reachable and license valid. |
Boot fails DATABASE_URL is required | Env not loaded | Confirm /etc/uscp/controlplane.env exists and the unit has EnvironmentFile=. |
Login redirects then errors invalid_redirect_uri | IdP redirect URI mismatch | Must be exactly https://<domain>/auth/callback. |
Login loops / SESSION_REVOKED | Role/attribute changed (epoch bumped) | Re-authenticate; expected right after the admin grant. |
| TLS cert not issued | DNS not resolving to the host, or port 80 blocked | dig +short <domain>; open 80; systemctl restart caddy; check journalctl -u caddy. |
| Can read other tenants' data | Connected as a superuser (RLS bypassed) | Switch DATABASE_URL to a non-superuser role — see Database hardening. |
Break-glass setup → NO_BOOTSTRAP_TENANT | No bootstrap tenant configured | Ensure USCP_TENANT_SLUG/USCP_DEV_TENANT_SLUG is set and provisioned. |
Break-glass setup → ADMIN_REQUIRED / WRONG_TENANT | Not an admin of the bootstrap tenant | Grant admin in the bootstrap tenant first (see Bootstrapping). |
go: command not found during install | Go not on PATH | export PATH=$PATH:/usr/local/go/bin, or pass USCP_BINARY=. |
| Go too old | Build needs Go ≥ 1.26.4 | Install newer Go, or supply a prebuilt USCP_BINARY. |
Logs:
# VM
sudo journalctl -u controlplane -n 200 --no-pager
sudo journalctl -u caddy -n 100 --no-pager
# Kubernetes
kubectl -n uscp logs deploy/uscp --tail=200
You're done. The platform is installed, hardened, backed up, and recoverable. For deeper operational runbooks see docs/site/docs/how-to/ and the deploy/ tooling in the repository.