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: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: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
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 (inDevelopmentthe same text is logged as aWarning). SetProvidertoPostgreSQLorSqlServerand supplyConnectionStringfor anything real;Provider=InMemoryis 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. SettingConnectionStringswitches it to a durable store; leaving it empty keeps the in-memory store, whateverProvidersays.ProviderisPostgreSQL(also what an empty key means) orSqlServerfor EF Core, orRedis— thenConnectionStringis a Redis connection string andKeyPrefix(defaultveriqa: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 onRedishas no database to share, so the trail then needs a connection string of its own. See Audit sink.
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 whenVeriqa: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.
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:
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
OutsideDevelopment 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:
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.
…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.
4. Declare your client
The application that signs users in is registered as an OIDC client underVeriqa: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)
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 correcthttps
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
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:
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 athttps://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:
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.
8. Verify
Readiness criterion: steps 1–4 green. The process is up, dependencies answer, discovery returnshttps 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 asDegraded, never asUnhealthy, by design: an unreachable messaging app must not take a working replica out of rotation.curl -ftherefore 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 sayhealthy, 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 isdocker 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.