On-Premises Installation
This guide covers getting the deployment files, configuring the .env file, installing Docker, and starting the Authnull On-Premises stack.
Step 1: Get the Deployment Files
Clone the deployment repository authnull on the server:
git clone https://github.com/authnull0/authnull.git
cd authnull
The repository contains deployment files only - no application source. Every image is pulled from docker-repo-public.authnull.com.
| File | Purpose |
|---|---|
docker-compose.yml | The stack definition |
.env | Configuration read by Docker Compose - a blank template |
nginx/nginx.conf | Nginx configuration for a domain with HTTPS |
nginx/nginx-ip.conf | Nginx configuration for a bare IP address over HTTP |
db-init/ | Database schema and migrations, applied automatically on every start |
Step 2: Configure Environment Variables
Open .env. Every value you must set is marked # [REQUIRED] on the line above it; everything else is preset. To list them:
grep -n -A1 '\[REQUIRED\]' .env
To keep the template untouched, copy it to .env.prod (it is ignored by git), fill that in, and add --env-file .env.prod to every docker compose command.
Database credentials
These are the credentials for the PostgreSQL container the stack creates. Choose any user and password; each pair must be identical.
DB_USER=authnull
DB_PASSWORD=choose-a-strong-password
POSTGRES_USER=authnull
POSTGRES_PASSWORD=choose-a-strong-password
Organization
The organization's short name, for example acme. It becomes part of the tenant name, default.<ORG_NAME>.<domain>.
ORG_NAME=customer-org-name
Access mode
Uncomment one of the two blocks in .env, and replace the placeholders. URLs must not end with a slash.
A) Domain with HTTPS - use with nginx/nginx.conf. Needs DNS records and certificates (see Network Access). Replace example.com with the customer's domain, <org> with ORG_NAME, and <vm-ip> with the server's IP:
SYSTEM_URL=https://onprem.customer-domain.com
SYSTEM_IP=SERVER_IP
DOMAIN_URL=default.customer-org-name.customer-domain.com
SSC_SIGNIN_URL=https://onprem.customer-domain.com/ssc/signin
SSC_BASE_URL=https://onprem.customer-domain.com
CLIENT_URL=https://onprem.customer-domain.com
TENANT_BASE_DOMAIN=customer-domain.com
WEBAUTHN_RP_ID=customer-domain.com
WEBAUTHN_ORIGIN=https://onprem.customer-domain.com,https://default.customer-org-name.customer-domain.com
ROOT_URL=https://onprem.customer-domain.com
SSC_URL=https://onprem.customer-domain.com/ssc/signin
AUTHNULL_LOGOUT_URL=https://onprem.customer-domain.com/ssc/signin
B) Bare IP over HTTP - use with nginx/nginx-ip.conf. No DNS or certificate needed. Replace <vm-ip> with the server's IP and <org> with ORG_NAME:
SYSTEM_URL=http://SERVER_IP
SYSTEM_IP=SERVER_IP
DOMAIN_URL=default.customer-org-name.SERVER_IP
SSC_SIGNIN_URL=http://SERVER_IP/ssc/signin
SSC_BASE_URL=http://SERVER_IP
CLIENT_URL=http://SERVER_IP
TENANT_BASE_DOMAIN=SERVER_IP
WEBAUTHN_RP_ID=SERVER_IP
WEBAUTHN_ORIGIN=http://SERVER_IP
ROOT_URL=http://SERVER_IP
SSC_URL=http://SERVER_IP/ssc/signin
AUTHNULL_LOGOUT_URL=http://SERVER_IP/ssc/signin
DOMAIN_URL identifies the tenant: the backend takes its second dot-separated label as the organization's database name, so it must be default.<ORG_NAME>.<domain>, and <ORG_NAME> must match the organization you create in Organization and Tenant Setup.
In bare-IP mode, DOMAIN_URL is a tenant ID, not an address - for example default.acme.10.0.0.5. Users still browse to http://SERVER_IP.
TENANT_BASE_DOMAIN must be the part of DOMAIN_URL after default.<ORG_NAME>.. When the organization is created, its tenant is stored as default.<ORG_NAME>.<TENANT_BASE_DOMAIN>, and sign-in finds the tenant by matching that against DOMAIN_URL - if the two differ, sign-in fails with "Tenant not found or inactive".
SSC_SIGNIN_URL must not be empty, or /tenant/login reloads forever.
Passkeys (WebAuthn) need a domain, so they do not work in bare-IP mode. Push MFA and TOTP do.
Per-install secrets
Generate each value and paste it in:
| Variable | Generate with |
|---|---|
JWT_SECRET | openssl rand -hex 32 |
INTERNAL_API_KEY | openssl rand -hex 32 |
REDIS_PASSWORD | openssl rand -hex 32 |
TOTP_ENCRYPTION_KEY | openssl rand -hex 32 (64 hex characters) |
ENCRYPTION_KEY | openssl rand -hex 16 (exactly 32 hex characters) |
Leaving JWT_SECRET empty signs login tokens with a blank key; leaving INTERNAL_API_KEY empty breaks policy lookups and auditing between services.
Do not change ENCRYPTION_KEY or TOTP_ENCRYPTION_KEY after the stack has run. Changing them does not fail loudly - it makes existing encrypted data unreadable and locks every user out of TOTP MFA. Keep a backup of the filled-in .env, and never commit it to git.
Email (SMTP)
AuthNull sends verification and invitation emails through the customer's mail relay:
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
[email protected]
SMTP_PASSWORD=sender-password
Sign-in factors
Set this so the sign-in page offers TOTP and passkeys directly. The mobile wallet is no longer used, and an empty value can send users with older console builds into a wallet-pairing screen:
WALLET_ONBOARDING_REQUIRED=false
Push MFA (Firebase)
Only needed when an organization's MFA provider is AuthNull's own app, whose pushes go through Firebase Cloud Messaging. Save the Firebase service account file AuthNull provides as secrets/fcm-service-account.json in the repository folder, and set:
FCM_SERVICE_ACCOUNT_JSON=/run/secrets/fcm-service-account.json
If the file is missing when the stack starts, Docker creates an empty directory with that name and push notifications silently stop working. Check with ls -la secrets/. Never commit secrets/ - it is ignored by git.
Optional settings
Integrations further down in .env (Twilio, Duo, Okta, Azure or AWS storage, push MFA tuning) are disabled while blank. A few worth knowing:
| Variable | Notes |
|---|---|
TWILIO_FROM_NUMBER | The sender number for SMS. TWILIO_PHONE is not read |
TRUSTED_PROXIES | Set to the Nginx address, or every client IP is logged as the proxy's |
DB_MAX_OPEN_CONNS, DB_MAX_IDLE_CONNS | Per database. The total, (organizations + 1) × DB_MAX_OPEN_CONNS, must fit within PostgreSQL's max_connections |
MFA_PUSH_SWEEP_ENABLED | Set true on exactly one replica |
MFA_PUSH_ALLOW_UNSIGNED | Empty means push signatures are enforced |
MFA_PUSH_RISK_ENABLED | Leave false until about a week of activity has built up |
ORG_DISCOVERY_ENABLED | Email-to-organization lookup. Off by default, because it allows account enumeration |
Port conflicts
PAM_HOST_PORT defaults to 9090, which Cockpit uses on many Linux installs. Change it if it collides.
Step 3: Install Docker and Docker Compose
Connect to the server over SSH, then install Docker and Docker Compose.
1. Update the package index
sudo apt-get update && sudo apt-get upgrade -y
2. Install prerequisite packages
sudo apt-get install -y ca-certificates curl
3. Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
4. Add the Docker repository to APT sources
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
5. Install Docker Engine, CLI, and Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
6. Start and enable the Docker service
sudo systemctl enable --now docker
sudo systemctl status docker
7. Allow your user to run Docker without sudo
sudo usermod -aG docker $USER
newgrp docker
Without this step, Docker commands fail with permission denied while trying to connect to the docker API; prefix them with sudo instead.
8. Verify the installation
docker --version
docker compose version
Step 4: Start the Stack
From the authnull directory:
docker compose pull
docker compose up -d
Start-up is ordered by health checks: once postgres and redis are healthy, did-schema-init applies the schema and migrations, then authnull-service starts, then the Admin UI.
Do not add --build. The compose file has a build: section for authnull-service, but its source is not in this repository - always pull the published image.
Step 5: Verify Container Status
docker compose ps -a
Containers are named onprem-<service>-1, after COMPOSE_PROJECT_NAME in .env. All services should show Up (and healthy where a health check is defined). The two one-off jobs, did-schema-init and cassandra-init, should show Exited (0). If did-schema-init exited with any other code, check it with docker compose logs did-schema-init.
Check the backend directly:
curl -s http://127.0.0.1:8080/system/v1/health
It should return {"status":"ok"}.
Updating the Stack
Images are published under fixed tags, and a new build is pushed under the same tag. docker compose up reuses an image that is already on the server, so always pull first:
docker compose pull <service>
docker compose up -d <service>
For example, docker compose pull did-react && docker compose up -d did-react updates the Admin UI.
Every docker compose up also replays all database migrations against the master database and every organization's database. If a migration fails, did-schema-init exits with an error and authnull-service does not start - check docker compose logs did-schema-init.
docker compose down stops the stack and keeps its data. docker compose down -v also deletes every data volume - PostgreSQL, Redis, recordings, and uploads.
authn-service must be tag 2.0.0 or later. Older tags have no push MFA: database logins are refused even when an MFA provider is configured.
Next Steps
Continue to Network Access: Domain or IP to publish the stack through Nginx.