Skip to main content
In self-hosted mode Veriqa runs as its own process: a standalone OIDC auth server in a container (OpenIddict inside, though your application never needs to know that). Your application talks to it over standard OpenID Connect and does not reference a single Veriqa package.
Compliance-friendly path. Because your application is a plain OIDC client, no Veriqa component enters your build or your SBOM. If your organization runs license scanners over dependencies, this is the mode to pick — you integrate over a protocol, not over a package. The embedded alternative is the .NET quickstart.

1. Get the image

Every release is published to the container registry of the public repository:
Without a tag Docker takes latest — the newest release. To pin a release in production, add its version as the tag (registry.gitlab.com/veriqa/veriqa:<version>).
The published image is linux/amd64. On another architecture, build it from the repository root — build/docker/Dockerfile publishes the same standalone host — and use your local tag in place of the image name in the commands below:
The image is multi-stage (SDK build → ASP.NET runtime), runs as a non-root user (UID 1654), exposes port 8080 over HTTP and ships a HEALTHCHECK against /health/live. TLS is expected to be terminated in front of it — see step 6.

2. Configure it

The container reads the same configuration as any ASP.NET Core app, so every setting can be supplied as an environment variable. Nested keys use a double underscore as the separator: Veriqa:OpenIddict:Database:Provider becomes Veriqa__OpenIddict__Database__Provider.
docker run
Secrets on the command line land in your shell history and in docker inspect. Use an --env-file, Docker/Kubernetes secrets or your secret manager instead — the sample above is spelled out only to show the key names.
The stores are configured independently and may point at the same database:
  • Veriqa:OpenIddict:Database — OIDC clients, authorizations and tokens. There is no default: a container with an empty section fails to start — the startup check refuses and names the fix (in Development the same text is logged as a Warning). Set Provider to PostgreSQL or SqlServer and supply ConnectionString for anything real; Provider = InMemory is the explicit choice of the volatile store: every restart drops issued tokens — fine for development and for a single-instance connector.
  • Veriqa:TransactionEngine:Store — the sign-in transactions themselves. Setting ConnectionString switches it to a durable store; leaving it empty keeps the in-memory store, whatever Provider says. Provider is PostgreSQL (also what an empty key means) or SqlServer for EF Core, or Redis — then ConnectionString is a Redis connection string and KeyPrefix (default veriqa:tx:) prefixes the keys. Redis keeps no collected claims store.
  • Veriqa:Logging:Store — the audit trail, optional: by default it shares the transaction database. A transaction store on Redis has no database to share, so the trail then needs a connection string of its own. See Audit sink.
Provider values are case-insensitive. For SQL Server the connection string takes the usual SqlClient form: Server=sqlserver;Database=veriqa;User Id=veriqa;Password=…. The resolvers are fail-fast: an unrecognized provider value, or a relational provider without a connection string, stops the container at startup instead of silently falling back.

Audit sink

Audit records are written only when Veriqa:Logging:Mode is Audit. Two optional keys decide where they go, and each falls back on its own: Except on Redis. With Veriqa:TransactionEngine:Store:Provider = Redis the sink borrows nothing: a Redis connection string is no database for the audit trail. Set Veriqa:Logging:Store:ConnectionString and the sink runs on its own Provider, or on PostgreSQL when that is unset; leave it unset and the trail stays in memory. Leave both unset and the trail lives in the transaction database. Give it a database of its own when:
  • the auditor should own the account the records are kept under, separate from the one the sign-in path writes with;
  • the load and backup profiles differ: transactions are gone within minutes, audit records stay for RetentionDays;
  • transactions stay in memory while the trail must survive a restart — set only Veriqa:Logging:Store:ConnectionString.
A provider of its own on top of a borrowed connection string is refused at startup, with a message naming both Provider keys: set the audit connection string as well, or drop the provider. When the audit connection string differs from the transaction one, readiness gets a check of its own (postgresql-audit or sqlserver-audit). An existing deployment. An installation that ran without these keys keeps its AuditRecords in the transaction database. Setting Veriqa:Logging:Store:ConnectionString does not move them: the new database starts with an empty table, and the rows left behind are no longer swept by retention. Copy them over, or delete them, as part of the switch. Audit mode needs a durable sink. With Veriqa:Logging:Mode = Audit and neither connection string set, records would be kept in memory and lost on restart — so outside Development the container refuses to start:
With Veriqa:TransactionEngine:Store:Provider = Redis, with or without its connection string, the text names only 'Veriqa:Logging:Store:ConnectionString': that is the one key that fixes it. In Development the same text is logged as a Warning. The check runs at startup: a switch to Audit made later without a restart is not checked again.

3. Provide the token certificates

Outside Development the server refuses to start without both an OpenIddict signing and an encryption certificate — there are no auto-generated development keys to fall back on. Supply each one either as a file path or inline as Base64 PFX. Any RSA-2048 (or stronger) certificate will do, and a self-signed one is enough. One way to issue both with OpenSSL:
The PFX_PASSWORD variable holds the password you then pass as …CertificatePassword. Keep the .key files out of the image and the repository. openssl creates both .pfx files as -rw-------, owned by the user that ran it — which matters as soon as you mount them, see the warning below.
The …Path variants (SigningCertificatePath / EncryptionCertificatePath) take a path to a PFX mounted into the container — usually the better option, since the Base64 form makes for very long environment values.
A mounted PFX has to be readable by the container user. The image runs as UID 1654 (step 1), while the files the recipe above produces are readable only by the host user that issued them. Mounted as they are, the container exits at startup with CryptographicException: Error occurred during a cryptographic operation — a message that names neither the path nor the access denial. Before mounting, hand the files to the image user:
chown is the better of the two ways: the files stay closed to everyone but the image user. It does need root on the host — where you do not have it, chmod 644 signing.pfx encryption.pfx also lets the container read them, but it makes the PFX readable by every local user, while the password that unlocks it sits in the container environment next to it. Use chmod only on a host that is yours alone. The .key files are not mounted at all, and the Base64 form is unaffected — there the certificate travels in an environment value, not as a file.
These certificates are what your issued tokens are signed and encrypted with. Keep them stable across restarts and shared across instances — rotating them invalidates every live token — and treat the passwords as secrets.

4. Declare your client

The application that signs users in is registered as an OIDC client under Veriqa:OpenIddict:Clients. The image ships no clients, so this step is mandatory — and every client is validated at startup: a missing ClientId, an empty AllowedRedirectUris or empty AllowedScopes stops the container with a message naming the offending entry. Alongside an env-file this is easier to express as JSON: mount a veriqa.json settings file into the container at /app/config/veriqa.json (the variable VERIQA_SETTINGS_FILE moves it elsewhere; if no file exists at the path it names, the container does not start). The file may carry any Veriqa setting, not only clients; a key set through the environment or --env-file wins over the same key in the file:
veriqa.json
redirect_uri is matched exactly against AllowedRedirectUris — wildcards are not supported, URIs must be absolute, and http is accepted only for loopback addresses (localhost, 127.0.0.1, [::1]) in development. The entry above has no ClientSecret, which makes my-app a public client: it is identified at /connect/token by PKCE (S256) alone, and Veriqa requires PKCE from it. Add a ClientSecret and the client becomes confidential — /connect/token then expects the secret, and PKCE is optional. The entry and the OIDC library of your application have to agree on which of the two it is: a library configured with a secret against an entry without one is refused, and so is the reverse. A secret is mandatory only for a client with AllowClientCredentials or AllowIntrospection. The scopes decide what your application may receive, not what the channel has: Telegram, for one, never hands over an email and gives the phone number only when asked. Veriqa can ask for a missing phone number or email inside the same sign-in — add the phone / email scope and raise the claim’s level: Phone number, Getting the phone number and email.

5. Point your application at it

Your app is a standard OIDC client. Nothing Veriqa-specific goes into it — this is the same configuration you would write for any OpenID Connect provider. In ASP.NET Core it comes from one package:
Program.cs (your application, not Veriqa)
The user’s avatar (picture) is gated by the Veriqa-specific avatar scope: add options.Scope.Add("avatar") and options.GetClaimsFromUserInfoEndpoint = true, and list avatar in the client’s AllowedScopes from step 4. The avatar is never put into the id_token — see OIDC + Veriqa explainer. Discovery, the authorization code flow and token issuance are ordinary OpenIddict behavior, so any conformant OIDC client library works — .NET, Node, Go, Java, Python. The block above is the registration only. To run the sign-in of step 8 you also need the pipeline (app.UseAuthentication(), app.UseAuthorization()), an endpoint that issues the challenge and the two routes a failed sign-in ends on — /signin-cancelled, which AccessDeniedPath points at, and /signin-failed, where OnRemoteFailure redirects. A complete client program, all of them included, is in step 5 of the .NET quickstart. That program is written for the in-process quickstart, so two of its values are its own: Authority points at https://localhost:7300, and it asks for the email scope, which the client declared in step 4 does not allow — either drop options.Scope.Add("email") or add email to its AllowedScopes. The rest applies as written.

6. Put it behind a reverse proxy

The container speaks HTTP; TLS terminates at your proxy. For OpenIddict to build correct https URLs, the proxy must forward X-Forwarded-Proto / X-Forwarded-For, and the host must be told which proxies to trust — in the same veriqa.json from step 4:
veriqa.json
This is not optional in a container. By default only loopback is trusted, and a request arriving through the container network comes from the bridge gateway — so an unconfigured host silently ignores X-Forwarded-Proto, keeps treating the request as http, and even /.well-known/openid-configuration answers invalid_request (“This server only accepts HTTPS requests”). Once the proxy network is listed, discovery returns https URLs and sign-in works. Leave the proxy room for request headers as well. Sign-in requests carry cookies: Veriqa’s own and, when your application shares the host name or a parent domain with it (all localhost ports count as one host during development), the client’s OIDC correlation, nonce and session cookies. NGINX caps a single header at 8k by default and answers a longer one with 400 Request Header Or Cookie Too Large — raise the limit in the server block:
The proxy is also where per-IP rate limiting belongs. The core counts its limits per route, per user and per client, never per address, so a limiter keyed by client IP (limit_req in NGINX, a WAF rule, fail2ban) is part of this same step — see Preparing for production. Behind a reverse proxy, a page of its own, covers which paths the proxy has to pass, the WebSocket of the sign-in page and a ready-made NGINX configuration.

7. Enable channels

Channel setup — bot tokens, webhook registration, Meta Cloud API, SMTP — is identical in both delivery modes and lives in Channel setup. One detail is specific to self-hosting: the channel webhooks must be reachable from the internet at https://auth.your-domain.com/api/channels/…, so the reverse proxy has to expose those paths. At least one channel is mandatory — without it there is nowhere to confirm a sign-in. The command in step 2 enables Telegram; here is what its keys mean, so you can reach a working sign-in without leaving this page: Veriqa registers the webhook in the Bot API itself on startup — you do not call setWebhook manually. Check the registration against the Bot API:
Expect url to equal https://auth.your-domain.com/api/channels/telegram/webhook and last_error_message to be absent. The container log also carries Telegram webhook registered: …, but only at the Information level: the image in Production logs from Warning, so there the line is not written. The section is validated at startup like every other one: an enabled adapter with no BotToken, no WebhookSecretToken, or with a relative or non-HTTPS WebhookBaseUrl stops the container with a message naming the key.
UpdateMode=Polling needs no public URL — handy to verify sign-in before the proxy is configured, but not for a deployed instance. Only one process may poll a bot: the Bot API hands the update queue to a single getUpdates caller, so a second instance on the same token receives nothing — see Running more than one instance.

8. Verify

Readiness criterion: steps 1–4 green. The process is up, dependencies answer, discovery returns https URLs, the channel is registered — the instance is deployed. Step 5 checks your integration (the client and the scenario), not the server itself.
1

Check liveness

curl -f http://localhost:8080/health/live — the process is up.
2

Check readiness

curl -f http://localhost:8080/health/ready — configured dependencies (database, Redis, RabbitMQ) answer. This is the probe to wire into your orchestrator.
3

Check discovery

curl https://auth.your-domain.com/.well-known/openid-configuration returns the OIDC metadata with https URLs. If they come out as http, revisit step 6.
4

Check that the channel is up

For Webhook mode: getWebhookInfo of the bot returns your /api/channels/telegram/webhook as url with no last_error_message — see step 7. With an empty url there is nothing to confirm sign-ins with. In Polling mode there is no such call: read /health/channels instead, and mind what it does not answer (below).
5

Run a sign-in

Trigger sign-in from your application and confirm it in a trusted channel on your phone. With Email — and with any channel when Veriqa:LoginConfirmation:Mode is OnWebPage — the sign-in page then asks once more: confirm there too.
What /health/channels answers — and what it does not. The endpoint reports every enabled channel separately, and the per-channel value is what you read: healthy, unhealthy or timeout.
  • The HTTP status is always 200. A channel outage is reported as Degraded, never as Unhealthy, by design: an unreachable messaging app must not take a working replica out of rotation. curl -f therefore succeeds even with every channel down — alert on the body, not on the status code. For the same reason channels are absent from /health/ready.
  • The report may be stale. It is a snapshot refreshed channel by channel — each channel at most once in its own interval, by default once every 15 seconds (Veriqa:Channels:{Channel}:HealthProbeInterval) — and only a read moves it: the read that finds a channel’s snapshot too old still answers from that old one and refreshes it in the background. The first read after a channel goes down can still say healthy, the next one tells the truth — so poll the endpoint on an interval if you alert on it.
  • It does not know who owns the update queue. The probe is an authenticated call to the platform, so a wrong or revoked token does come out as unhealthy — but a token that is valid and already taken by another poller responds normally. See Running more than one instance.

9. Common first-run failures

Almost everything that breaks on a first run breaks at startup — the container does not stay alive half-broken, it exits with a message naming the key. So the first move on any failure is docker logs veriqa.

Running more than one instance

A single container needs nothing extra. Scaling out requires shared state: Leave the first two unset and each instance keeps its own in-memory keys — fine for one container, broken for several. A shared volume for KeysDirectory works as an alternative to Redis. One more thing does not scale by itself: update delivery in Polling mode. The Bot API hands the update queue to a single getUpdates caller, so a second instance on the same bot token receives nothing at all — while reporting itself healthy, because its token is valid and the platform answers it. Only the log gives it away: the polling loop repeats the 409 refusal of the Bot API, written out as Conflict: terminated by other getUpdates request — the number itself is not in the line. Scale out on Webhook mode, where every instance is fed by the proxy, or give each installation its own bot — that last one applies to a staging installation standing next to production just as much as to a second replica.

Next steps

Channel setup

Telegram, WhatsApp, other channels and Email — end to end.

Configuration reference

Every section, key and default in one place.

Preparing for production

What to check before shipping.

Storage

Transaction store, retention and migrations.

Install as an OS service

The same server without Docker — systemd or a Windows service, from an archive.

Behind a reverse proxy

The paths to pass, the WebSocket and an NGINX configuration.

Phone number and email

Ask for what the channel did not give, inside the same sign-in.