Status: 104 capabilities — 20 GA, 84 Beta. Enforcement is monitor-only by default. Honest status →

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.


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

  1. Browse to https://<your-domain>/auth/login.
  2. 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.

  1. 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, plus USCP_SMTP_* for email or USCP_LOCAL_ADMIN_TOTP_SECRET for authenticator, mode 0640). 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

  1. Google Cloud Console → *APIs & Services → Credentials → Create Credentials → OAuth client

ID*.

  1. Application type: Web application.
  2. Authorized redirect URIs: https://<your-domain>/auth/callback.
  3. Copy the Client ID and Client secret.
  4. Issuer: https://accounts.google.com.

Microsoft Entra ID (Azure AD)

  1. Entra admin center → App registrations → New registration.
  2. Redirect URI (platform Web): https://<your-domain>/auth/callback.
  3. Certificates & secrets → New client secret → copy the Value (not the ID).
  4. Client ID = Application (client) ID.
  5. Issuer: https://login.microsoftonline.com/<tenant-id>/v2.0 (use /v2.0).

Okta

  1. Okta admin → Applications → Create App Integration → OIDC → Web Application.
  2. Sign-in redirect URI: https://<your-domain>/auth/callback.
  3. Copy Client ID and Client secret.
  4. Issuer: https://<your-org>.okta.com (or your custom auth-server issuer).

Keycloak (self-hosted / air-gapped)

  1. Import the ready-made realm at deploy/keycloak/realm-export.json, or create a realm.
  2. Clients → Create → a confidential client with Valid redirect URI

https://<your-domain>/auth/callback and client authentication on.

  1. Copy the Client ID and the client secret from the Credentials tab.
  2. Issuer: https://<keycloak-host>/realms/<realm>.

Whatever the IdP, if login fails with a redirect/invalid_redirect_uri error, the registered redirect URI does not exactly match https://<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, or verify-full with 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_URL is the https URL.

8080 and 5432 are never public.

  • [ ] SSO + passkey MFA enforced for every human (already enforced — no password auth).
  • [ ] cosign verify passed 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 controlplaneReinstall the previous binary + restart
Containerdocker pull …:<NEW> then re-runRe-run with the previous tag
Kuberneteshelm -n uscp upgrade uscp deploy/helm/uscp --set image.tag=<NEW> (rolling, zero-downtime)helm -n uscp rollback uscp
VM appliancein-VM updatectl check → signed delta updateper-module rollback
Air-gapTransfer + verify a new signed binary, replace, restartRestore 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 200DB or license not readyCheck logs; confirm DATABASE_URL reachable and license valid.
Boot fails DATABASE_URL is requiredEnv not loadedConfirm /etc/uscp/controlplane.env exists and the unit has EnvironmentFile=.
Login redirects then errors invalid_redirect_uriIdP redirect URI mismatchMust be exactly https://<domain>/auth/callback.
Login loops / SESSION_REVOKEDRole/attribute changed (epoch bumped)Re-authenticate; expected right after the admin grant.
TLS cert not issuedDNS not resolving to the host, or port 80 blockeddig +short <domain>; open 80; systemctl restart caddy; check journalctl -u caddy.
Can read other tenants' dataConnected as a superuser (RLS bypassed)Switch DATABASE_URL to a non-superuser role — see Database hardening.
Break-glass setup → NO_BOOTSTRAP_TENANTNo bootstrap tenant configuredEnsure USCP_TENANT_SLUG/USCP_DEV_TENANT_SLUG is set and provisioned.
Break-glass setup → ADMIN_REQUIRED / WRONG_TENANTNot an admin of the bootstrap tenantGrant admin in the bootstrap tenant first (see Bootstrapping).
go: command not found during installGo not on PATHexport PATH=$PATH:/usr/local/go/bin, or pass USCP_BINARY=.
Go too oldBuild needs Go ≥ 1.26.4Install 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.