Deployment
deploy.sh is the single entrypoint. It wraps docker compose around
deploy/compose/stack/docker-compose.yml (the vendored MOSIP + walt.id
service definitions), layers a verifiably-go-specific override
(deploy/docker-compose.injiweb-fix.yml), and then runs the verifiably-go
image as a separate container on the same docker network.
deploy/compose/ is self-contained โ everything the compose file
needs (Caddyfile, Keycloak realm JSON, WSO2IS certs, Inji Certify data
CSVs, Mimoto bootstrap, oidc-ui nginx, seed scripts) lives there. The
tree preserves the sibling stack/ + injiweb/ layout the compose file
and seed scripts expect via relative paths.
Per-DPG deploy detail (each DPG's containers, env, Caddy, runtime configs,
databases) is indocs/dpg/. This doc covers the shared
mechanics; the guides cover each DPG end-to-end.
Prerequisites
- Docker Engine 20.10+ with the Compose v2 plugin (
docker compose, not the
legacydocker-compose). The compose file is Compose-spec (name:, notversion:). - git, bash, curl, openssl on PATH (openssl generates the hub signing
key + CREDEBL secrets; a missing openssl fails late with a mount error). - RAM: ~8 GiB for
all, ~6 GiB forinji, ~4 GiB forwaltid. - Ports: localhost/port mode exposes one port per service (e.g. 3001-3005, 7001-7003,
8082, 8091-8094, 5001); per-subdomain mode needs 80 + 443 open instead.
First deploy (any host)
git clone <repo> && cd verifiably-gocp .env.example .env(or./deploy.sh setupfor an interactive wizard).- Set
VERIFIABLY_PUBLIC_HOSTin.envโ the one required knob, the address
browsers and sibling containers use to reach this host:- localhost dev โ
localhost - a VPS/EC2 reached by IP โ that public IP
- a public domain (per-subdomain mode) โ ALSO set
VERIFIABLY_HOSTS_PATTERN=https://%s.<domain>,
VERIFIABLY_PUBLIC_DOMAIN=<domain>,VERIFIABLY_LE_EMAIL=<email>, and create
*.<domain>DNS A records pointing here (with 80/443 open for Let's Encrypt).
- localhost dev โ
./deploy.sh up <all|waltid|inji|credebl>โ starts the scenario's DPG services and
bootstraps their OIDC clients + secrets (idempotent; safe to re-run)../deploy.sh run <same scenario>โ builds + starts the verifiably-go container.- Verify โ see Verifying a deploy.
Generated / bootstrapped files โ do NOT hand-edit (regenerated on each up/run):
config/backends.json, config/auth-providers*.json, deploy/compose/stack/wso2-deployment.toml,
deploy/compose/stack/Caddyfile.public, config/docker-compose.injiweb-fix.rendered.yml.
Secrets are auto-generated and persisted so re-runs are stable: config/wso2is.env (WSO2
DCR), deploy/compose/credebl/config/credebl.env (CREDEBL), config/trust-signing-key.pem
(hub). The demo IdP/TLS keystores under deploy/compose/**/certs*/ are committed demo
material โ replace them for any non-demo use.
Subcommands
| Command | Does |
|---|---|
./deploy.sh up <all|waltid|inji|credebl> [--role <roles>] |
Brings up every DPG container the scenario (and role, if given) needs + seeds clients |
./deploy.sh run <all|waltid|inji|credebl> |
Builds + starts the verifiably-go container (run after up) |
./deploy.sh down <all|waltid|inji|credebl> |
Stops the verifiably-go container and the scenario's DPG services |
./deploy.sh status |
Lists running compose services + verifiably-go container state |
./deploy.sh config <all|waltid|inji|credebl> |
Regenerates config/backends.json + prints it (no container touched) |
Typical first run: ./deploy.sh up all && ./deploy.sh run all.
Scenarios
| Scenario | DPG services | IdPs (always both) | Translator |
|---|---|---|---|
all |
walt.id (issuer/wallet/verifier) + Inji Certify + Inji Certify Preauth + Inji Verify + Inji Web + Mimoto + eSignet + mock-identity + certify-nginx + certify-preauth-nginx + CREDEBL | Keycloak + WSO2IS | Yes |
waltid |
walt.id only | Keycloak + WSO2IS | Yes |
inji |
Inji Certify + Inji Verify + Inji Web + Mimoto + eSignet + mock-identity + certify-nginx + certify-preauth-nginx | Keycloak + WSO2IS | Yes |
credebl |
CREDEBL platform (also bundled into all) |
Keycloak + WSO2IS | Yes |
hub |
Trust Registry + portal โ a separate deployment (deploy/compose/hub/, its own .env) |
n/a | n/a |
backends.json is rendered per scenario so the UI never offers a DPG
whose backend isn't running. Auth providers are not scoped: every
scenario brings up both Keycloak and WSO2 Identity Server, and the
sign-in page always offers both. The scenario only decides which DPG
cards the user can pick from after auth. The WSO2IS OIDC client is
bootstrapped via scripts/bootstrap-wso2is.sh on every deploy.sh up,
regardless of scenario.
Verifying a deploy
./deploy.sh statusโ every service the scenario needs, plus theverifiably-go
container, should be Up.deploy.sh upruns a built-in OIDC discovery smoke gate per provider (you'll see
discovery gate: '<idp>' OKin the log). SetVERIFIABLY_STRICT_VERIFY=1to make a
failed gate abort the deploy instead of just warning.- Open
http://<host>:<port>(localhost mode) orhttps://<domain>(subdomain mode),
pick a role, and confirm the/authpage shows the Keycloak + WSO2 (+ eSignet) tiles. - Logs:
docker compose -p waltid logs -f <service>anddocker logs -f verifiably-go. - Destructive reset (wipes volumes โ DB + keystores):
./deploy.sh reset <scenario>.
Compose override pipeline
deploy/docker-compose.injiweb-fix.yml holds our additive compose
overlay. Relative paths inside an override get resolved against the
primary compose's directory, not the override's, so paths there use a
{{VERIFIABLY_GO_DIR}} placeholder that deploy.sh substitutes with the
absolute repo root at render time. The rendered file lands in
config/docker-compose.injiweb-fix.rendered.yml.
The override mounts:
-
deploy/injiweb-overrides/mimoto-bootstrap.propertiesโ/home/mosip/mimoto-bootstrap.properties
Fixes a Mimoto crash loop: upstream pointsspring.cloud.config.uriat
the Inji Web SPA (not a config server); Spring parses HTML, fails, and
exits 1. The patched bootstrap disables Spring Cloud Config. -
deploy/injiweb-overrides/mimoto-issuers-config.jsonโ both
injiweb-ui:/home/mosip/mimoto-issuers-config.jsonand
certify-nginx:/config/server/mimoto-issuers-config.json
Fixes two upstream issues: thewellknown_endpointpointed at a 404
path, and Mimoto'sIssuerConfigUtil.getIssuerWellknown()ignores the
field entirely and instead appends a string tocredential_issuer_host
โ so both fields need to contain/v1/certify. -
deploy/injiweb-overrides/certify-nginx.confโ/etc/nginx/conf.d/default.conf
Routes/.well-known/did.jsonand/v1/certify/credentials/status-list/
through the verifiably-go inji-proxy so we can patch kid fragments the
upstream did.json misses. See
architecture.md ยง Inji-proxy
for the why. -
deploy/injiweb-overrides/inji-verify-config.jsonโ/usr/share/nginx/html/assets/config.json
Inji Verify v0.16.0 ships without this render-order config; its UI
JSON.parses the fallthrough HTML, gets an empty object, and crashes
with "Cannot read properties of undefined" on every successful
verification.
Seed scripts + repair helpers
After docker compose up completes, cmd_up runs:
-
deploy/compose/injiweb/seed-esignet-client.shโ extracts the
wallet-demo-clientpublic key from its p12 keystore, converts to JWK,
and POSTs to eSignet's client-mgmt API. Idempotent; re-runs return
duplicate_client_idand exit 0. -
deploy/compose/injiweb/seed-mock-identity.shโ stuffs an identity
(individualId8267411072, PIN111111) into mock-identity so the
OTP login screen has something to authenticate. -
scripts/bootstrap-wso2is.shโ registers verifiably-go's OIDC client
via DCR; writesconfig/wso2is.envsobackends_for_dockercan pick
up the client_secret. -
repair_injiweb_client_redirect_uri(in deploy.sh) โ ensures the
eSignet client'sredirect_urislist contains
http://${PUBLIC_HOST}:3004/redirect. Needed because the seed script
registers the client ONCE and treats duplicates as success; if a
previous deploy used a different PUBLIC_HOST, eSignet rejects
/authorize withinvalid_redirect_uri. Repairs the DB row in place
and deletes the Redisclientdetails::wallet-demo-clientcache entry. -
recover_injiweb(before compose up) โ force-removes any eSignet /
mock-identity containers that might be stuck in a restart loop from a
previous run's entrypoint + writable-layer state pollution. -
certify-nginx reload (
scripts/start-container.sh, after verifiably-go
starts) โ verifiably-go is recreated with a fresh IP on everyup/run;
certify-nginxfronts the Inji auth-code well-known/credential routes through
theinjiproxyupstream (verifiably-go:8080). The nginx conf uses a
resolver+ variable upstream to re-resolve per request (โค30s), and
start-container.sh additionallynginx -s reloads certify-nginx so there is no
502 window. Symptom if missing: "selected schema missing" on/issuer/issue
right after building an Inji schema (the vendor's schemas get skipped on a 502).
Catalog reset. scripts/reset-authcode-catalog.sh [--yes] wipes the Inji
auth-code catalog back to empty (deletes credential_config + vc_credential_owner
rows, drops the vc_subject_* extraction views, restores the scope .properties
to their committed baseline, restarts inji-certify + injiweb-esignet) โ for a
fresh issuance test. Keeps vc_subject (base table) and identity_registry.
Environment variables
Every knob lives in a single file โ verifiably-go/.env โ which
deploy.sh sources at startup and passes to docker compose via
--env-file. Copy the template on first use:
cp verifiably-go/.env.example verifiably-go/.env
The template has every variable documented with sensible laptop defaults.
The key ones:
| Variable | Default | Purpose |
|---|---|---|
VERIFIABLY_PUBLIC_HOST |
172.24.0.1 |
The one knob to flip for single-host deployments. Host the browser uses for every service. Leave at default for laptop; set to your EC2 hostname / VPS IP / LAN address for remote |
PUBLIC_HOST |
${VERIFIABLY_PUBLIC_HOST} |
Alias read by the compose file + seed scripts |
VERIFIABLY_HOSTS_PATTERN |
(empty) | Optional per-subdomain mode. When set, every service URL is printf "$VERIFIABLY_HOSTS_PATTERN" "<slug>". See Per-subdomain deployment below |
VERIFIABLY_HOST_PORT |
8080 |
Host port mapped to the verifiably-go container |
VERIFIABLY_PUBLIC_URL |
http://${VERIFIABLY_PUBLIC_HOST}:${VERIFIABLY_HOST_PORT} |
Shown on deploy.sh run |
VERIFIABLY_IMAGE |
verifiably-go:local |
Image tag for the build |
VERIFIABLY_CONTAINER |
verifiably-go |
docker container name |
WALTID_{ISSUER,WALLET,VERIFIER}_PORT |
7001/7002/7003 |
Host ports for walt.id |
CERTIFY_NGINX_PORT |
8091 |
Inji Certify nginx (auth-code flow) |
CERTIFY_PREAUTH_PORT |
8094 |
Inji Certify pre-auth stanza |
INJI_VERIFY_{UI,SERVICE}_PORT |
3001/8082 |
Inji Verify |
INJIWEB_UI_PUBLIC_PORT |
3004 |
Inji Web SPA |
ESIGNET_PUBLIC_PORT |
3005 |
eSignet oidc-ui |
MIMOTO_PORT |
8099 |
Mimoto BFF |
KEYCLOAK_{PORT,REALM,CLIENT_ID} |
8180/vcplatform/vcplatform |
Keycloak OIDC wiring |
WSO2_{PORT,CLIENT_ID,CLIENT_SECRET} |
9443/verifiably_go_client/<generated> |
WSO2IS OIDC. CLIENT_SECRET populated by scripts/bootstrap-wso2is.sh on first up |
INJIWEB_P12_PASSWORD |
xy4gh6swa2i |
Matches the p12 in deploy/compose/injiweb/config/certs/ |
INJI_PROXY_EXTRA_KIDS |
(empty) | Pre-seed kids for the PRIMARY (auth-code) inji-proxy did.json handler |
INJI_PROXY_PREAUTH_EXTRA_KIDS |
(empty) | Pre-seed kids for the PRE-AUTH inji-proxy did.json handler |
VERIFIABLY_DEBUG_MOCK_MARKERS |
0 |
Show [mock] pills on surfaces still mock-backed |
VERIFIABLY_STATE_DIR |
state |
Path (inside the container) for session files and status-list bitmap. Mapped to a Docker named volume (verifiably-go-state) by deploy.sh |
VERIFIABLY_SESSION_SECRET |
(auto-generated) | AES-256-GCM key (any string; SHA-256'd internally) for encrypting session files. Auto-generated on first start and stored at $VERIFIABLY_STATE_DIR/session.key. Override here to keep sessions across full stack teardowns |
CREDEBL_LEDGER_URL |
http://test.bcovrin.vonx.io |
AnonCreds ledger HTTP URL for the CREDEBL agent |
CREDEBL_GENESIS_URL |
http://test.bcovrin.vonx.io/genesis |
AnonCreds genesis file URL |
CREDEBL_TAILS_FILE_SERVER |
https://tails.vonx.io |
Tails file server for AnonCreds revocation |
CREDEBL_SESSION_LIMIT |
500 |
Max concurrent Credo agent sessions (also controls INMEMORY_LRU_CACHE_LIMIT) |
CREDEBL_NGINX_RATE_LIMIT |
10r/s |
nginx rate-limit for /oid4vci, /oid4vp, /openid4vc paths |
CREDEBL_NGINX_RATE_BURST |
30 |
nginx burst allowance above CREDEBL_NGINX_RATE_LIMIT before HTTP 429 |
Inji auth-code: public issuer, activation, registries
These drive the Inji auth-code path (see
dpg/inji-certify-authcode.md). Most are derived
by deploy.sh cmd_up in subdomain mode โ pin them in .env only to override.
| Variable | Default / source | Purpose |
|---|---|---|
AUTHCODE_PUBLIC_URL |
derived (url_for inji-certify-authcode) |
Public mosip_certify_domain_url for the auth-code Certify โ public credential_issuer + status-list URL. |
ISSUER_DID_DOMAIN |
derived (host of AUTHCODE_PUBLIC_URL) |
Auth-code issuer DID = did:web:${ISSUER_DID_DOMAIN}. Unset โ certify-nginx (dev). |
AUTHCODE_ALLOWED_AUD |
http://certify-nginx:80/v1/certify/issuance/credential |
Pins certify's authn.allowed-audiences to the internal endpoint (eSignet issues the access token with the internal aud). Without it every claim 401s once domain_url is public. |
PREAUTH_PUBLIC_URL |
derived (url_for inji-certify-preauth) |
Public domain_url for the pre-auth instance. |
PREAUTH_DID_DOMAIN |
derived (host of PREAUTH_PUBLIC_URL) |
Pre-auth issuer DID, decoupled from ISSUER_DID_DOMAIN. |
INJI_AUTHCODE_CLIENT_ID |
wallet-demo-client |
OIDC client for the auth-code claim (also derives the PSU-token). |
INJI_AUTHCODE_CLIENT_KID |
wallet-demo-client-kid |
kid in the client_assertion. |
INJI_AUTHCODE_SCOPE |
mock_identity_vc_ldp |
Default credential scope. |
ESIGNET_BASE_URL |
derived (url_for esignet) |
eSignet authorize/token/JWKS base. Must be set when recreating inji-certify โ else it falls back to a firewalled :3005 and claiming hangs 133s (use deploy.sh up, not a bare recreate). |
SMTP_HOST/PORT/USER/PASSWORD/FROM/FROM_NAME |
.env |
Real email-OTP for registry-gated activation. SMTP_PASSWORD is a secret (e.g. a Gmail app password) โ never commit it. |
VERIFIABLY_REGISTRIES |
.env (JSON array) |
Configured external registries (Sunbird RC + federated agency registries) the bulk engine reads from. See dpg/registries.md. |
VERIFIABLY_REGISTRY_ADMIN_URL |
.env |
Link to the registry-admin console. |
INJI_AUTHCODE_CLIENT_KEY_PEM (the RSA key signing the client_assertion) is
extracted from oidckeystore.p12 by scripts/start-container.sh โ a runtime
secret, never in the repo.
Caddy hairpin aliases (required). In subdomain mode, caddy-public must alias
inji-certify-authcode.<domain>, inji-certify-preauth.<domain>, esignet.<domain>,
and walt-*.<domain> on the compose network, so in-cluster containers (Inji Verify
resolving an issuer did.json, certify reaching the eSignet JWKS, the wallet resolving
an offer) route internally instead of hairpinning the box's blocked :443. Missing
the inji-certify-authcode alias makes external verification of a claimed VC fail
with DidWebPublicKeyResolver: Connection timeout.
CREDEBL backends.json fields
The CREDEBL adapter also reads per-adapter fields from config/backends.json
(generated by deploy.sh). These are set inside the "config" object of the
CREDEBL entry:
| Field | Default | Purpose |
|---|---|---|
pollTimeoutSeconds |
120 |
How long FetchPresentationResult polls for a holder presentation before returning Pending. Increase for slow network paths or manual testing. |
Command-line override: set VERIFIABLY_ENV_FILE=/path/to/other.env ./deploy.sh ... to swap the entire file for one invocation (e.g. keep
.env pinned to laptop, ship .env.ec2 for a staging run).
Secrets to regenerate per deployment
Every country deployment must generate its own secrets. Do not reuse values from another deployment or from this repo's example files. The commands below produce cryptographically random values.
Run these once, before the first ./deploy.sh up hub, and keep the results in a secure vault (Bitwarden, 1Password, AWS Secrets Manager, etc.).
Hub โ deploy/compose/hub/.env
# 1. PostgreSQL password
openssl rand -hex 32
# 2. Session encryption key (AES-256-GCM)
openssl rand -hex 32
# 3. Admin panel password
openssl rand -base64 18
# 4. Grafana password
openssl rand -base64 18
Paste each output into the corresponding field in hub/.env:
| Field | Command above |
|---|---|
POSTGRES_PASSWORD |
#1 |
VERIFIABLY_SESSION_SECRET |
#2 |
VERIFIABLY_ADMIN_PASSWORD |
#3 |
GRAFANA_PASSWORD |
#4 |
Hub โ trust registry signing key
The Hub signs its trust registry JWT with an ES256 P-256 private key. Without a persistent key the Hub generates an ephemeral one on every restart โ all previously-issued trust tokens become invalid.
Generate and save the key once per deployment:
# Generate a P-256 key in PKCS8 format (the format the Hub expects)
openssl ecparam -name prime256v1 -genkey -noout -out /tmp/trust-key.pem
openssl pkcs8 -topk8 -nocrypt -in /tmp/trust-key.pem -out config/trust-signing-key.pem
rm /tmp/trust-key.pem
# Verify it loaded correctly (optional smoke-test)
openssl pkey -in config/trust-signing-key.pem -noout -text | grep "Private-Key"
The file config/trust-signing-key.pem is already git-ignored and already mounted as /app/config/trust-signing-key.pem inside the Hub container via the ../../../config:/app/config:ro volume in deploy/compose/hub/docker-compose.yml. No .env change needed โ the Hub reads it automatically when VERIFIABLY_TRUST_SIGNING_KEY is empty.
Hub โ independent verifier (hub-verifier-api)
The Hub can run its own Walt ID verifier so it can verify credentials
without depending on federated nodes having a verifier of their own.
It's opt-in via the verifier Docker Compose profile:
docker compose -f deploy/compose/hub/docker-compose.yml --env-file deploy/compose/hub/.env --profile verifier up -d
Config in deploy/compose/hub/.env:
| Variable | Default | Purpose |
|---|---|---|
VERIFIABLY_ROLES |
verifier |
Hub's own Go-app role โ the hub rarely needs to also issue or be a holder |
HUB_VERIFIER_PORT |
7053 |
Host port for the Walt ID verifier-api container (bind to 127.0.0.1:7053 in production โ no public exposure needed when behind Caddy) |
HUB_VERIFIER_BASE_URL |
http://localhost:7053 |
Base URL the verifier-api advertises in presentation_definition_uri/request_uri. Must be browser-reachable โ in TLS mode set to https://<VERIFIABLY_PUBLIC_DOMAIN>/verifier-api |
WALTID_VERSION |
0.18.2 |
Image tag for waltid/verifier-api โ pin to match the rest of your Walt ID deployment |
Caddy exposes it at /verifier-api/* (via handle_path, which strips the
prefix before proxying) โ deliberately not /verify*, which is already
routed to the Hub's own citizen-facing public verification portal served by
verifiably-go:8080 itself. Routing /verify* to the raw verifier container
would silently break that portal.
Verify it's up:
curl -s http://localhost:7053/openapi | grep -q verifier && echo "hub-verifier-api OK"
Do not commit this file. It is private โ possession equals the ability to sign arbitrary trust registry JWTs as your federation authority.
CREDEBL โ deploy/compose/credebl/config/credebl.env
Most CREDEBL secrets (PostgreSQL, MinIO, Keycloak, JWT, NextAuth) are auto-generated by scripts/bootstrap-credebl.sh on first run. One field is not auto-generated and must be changed manually:
# CRYPTO_PRIVATE_KEY โ AES passphrase used by CREDEBL to encrypt
# the platform admin wallet password before sending it to the Credo agent.
# Must be โฅ 32 characters. Any random string works.
openssl rand -hex 20
Set the output as CRYPTO_PRIVATE_KEY=<value> in credebl.env before running the CREDEBL bootstrap for the first time. Changing it after bootstrap requires re-encrypting the stored wallet password โ change it before, not after.
Quick checklist before going live
[ ] hub/.env โ POSTGRES_PASSWORD (openssl rand -hex 32)
[ ] hub/.env โ VERIFIABLY_SESSION_SECRET (openssl rand -hex 32)
[ ] hub/.env โ VERIFIABLY_ADMIN_PASSWORD (openssl rand -base64 18)
[ ] hub/.env โ GRAFANA_PASSWORD (openssl rand -base64 18)
[ ] config/trust-signing-key.pem (openssl ecparam + pkcs8, see above)
[ ] credebl.env โ CRYPTO_PRIVATE_KEY (openssl rand -hex 20)
Migrating to a remote host (EC2, dev VM, LAN)
One-variable flip:
# edit verifiably-go/.env
VERIFIABLY_PUBLIC_HOST=ec2-1-2-3-4.compute-1.amazonaws.com
That's it. The same .env drives every service โ backends.json / auth-providers.json stanzas, the compose file's ${PUBLIC_HOST} references (eSignet redirect, Mimoto's MIMOTO_URL injection, the patched Inji Web issuer catalog), and the eSignet client-redirect-URI repair helper.
Caveats:
- TLS: browsers reaching WSO2IS on
:9443self-signed will need cert trust. Keycloak on:8180is HTTP so unaffected. For a public-facing EC2 you'd typically drop a Caddy / ALB in front and re-pointVERIFIABLY_PUBLIC_HOSTplus the port vars at your TLS terminator. - Firewall: open
VERIFIABLY_HOST_PORT,KEYCLOAK_PORT,WSO2_PORT,CERTIFY_NGINX_PORT,INJIWEB_UI_PUBLIC_PORT,ESIGNET_PUBLIC_PORT,INJI_VERIFY_UI_PORTon the instance โ every one is visited from the browser. - Re-run
./deploy.sh up <scenario>after flipping PUBLIC_HOST so therepair_injiweb_client_redirect_urihelper re-registers the eSignet client's redirect URI on the new host. The helper is idempotent and only writes when the list diverges.
Per-subdomain deployment
If you'd rather run each service on its own subdomain (walt-issuer.example.com, inji-certify.example.com, ...) instead of one host with eleven different ports, set VERIFIABLY_HOSTS_PATTERN in .env:
VERIFIABLY_HOSTS_PATTERN=https://%s.example.com
The %s is the per-service slug. deploy.sh substitutes it for each service when it generates backends.json and auth-providers.json, so a single env var rewires every URL. Empty (default) preserves the legacy ${VERIFIABLY_PUBLIC_HOST}:${PORT} form, so localhost dev keeps working unchanged.
What %s becomes
deploy.sh writes URLs for these slugs (one per service the stack runs):
| Slug | What it is |
|---|---|
walt-issuer |
walt.id issuer-api |
walt-wallet |
walt.id wallet-api |
walt-verifier |
walt.id verifier-api |
inji-certify |
Inji Certify (auth-code stanza, via nginx) |
inji-certify-preauth |
Inji Certify (pre-auth stanza, via nginx) |
inji-verify |
Inji Verify backend |
inji-verify-ui |
Inji Verify SPA |
inji-web |
Inji Web wallet SPA |
mimoto |
Mimoto BFF |
esignet |
eSignet OIDC UI |
keycloak |
Keycloak IdP |
wso2 |
WSO2 IS IdP |
verifiably |
verifiably-go itself |
So VERIFIABLY_HOSTS_PATTERN=https://%s.example.com yields https://walt-issuer.example.com, https://inji-certify.example.com, and so on for all thirteen.
What you need to wire (one-time, on the host)
A working Caddy reverse proxy ships in the repo โ you don't need to write your own. Three things to do on the host:
1. DNS
Point either:
- A wildcard
Arecord*.example.com โ <host-ip>(one entry covers every service), or - One
Arecord per slug โwalt-issuer.example.com,walt-wallet.example.com, ... thirteen total. Use this if your DNS provider charges per record-type or you prefer explicit per-service control.
Verify with dig walt-issuer.example.com +short from anywhere โ should resolve to your host's public IP.
2. Caddyfile (already written for you)
The repo ships a complete Caddyfile at:
verifiably-go/deploy/compose/stack/Caddyfile.public
It declares one block per slug pointing at the right container + internal port โ including the WSO2-on-self-signed-HTTPS upstream and Inji Verify's quirky port-8000 SPA. Don't edit unless you want a different subdomain scheme; the {$VERIFIABLY_PUBLIC_DOMAIN} placeholder is filled in from .env at container start.
The matching caddy-public service is registered in docker-compose.yml behind the subdomain profile. deploy.sh automatically passes --profile subdomain to compose when VERIFIABLY_HOSTS_PATTERN is non-empty, so you don't run anything new โ ./deploy.sh up <scenario> brings Caddy up alongside the rest.
Hairpin NAT (container โ public subdomain)
A container that calls a service by its public name (e.g. the walt.id wallet-api
resolving an OID4VCI credential_offer_uri at walt-issuer.<domain>, or an OID4VP
request_uri at walt-verifier.<domain>) would otherwise route to the box's own
public IP :443 and time out โ most cloud hosts don't hairpin traffic from the docker
bridge back to themselves. Two mechanisms keep those calls on the docker network:
verifiably-gois started with--add-host <slug>.<domain> โ caddy-public-ip
for every public slug (compute_host_aliasesinscripts/start-container.sh).- compose-managed services rely on
caddy-publicnetwork aliases โ the
caddy-publicservice answers to each public FQDN on the docker network
(inji-certify-preauth,esignet,walt-issuer,walt-verifier,walt-wallet),
so Docker's embedded DNS resolves them to Caddy's internal IP, where TLS terminates
and the request is proxied to the backend. Add more FQDNs to thataliases:list in
docker-compose.ymlif a new internal container starts calling a public name.
Set these in .env:
VERIFIABLY_HOSTS_PATTERN=https://%s.example.com
VERIFIABLY_PUBLIC_DOMAIN=example.com # apex must match the pattern
VERIFIABLY_LE_EMAIL=ops@example.com # Let's Encrypt account email
If VERIFIABLY_LE_EMAIL is left empty Caddy falls back to an internal CA โ fine for local-dev with /etc/hosts overrides, but browsers will reject the cert on a public deploy.
3. Firewall
Caddy needs ports 80 + 443 reachable from the public internet. Per-service container ports stay internal โ they're not bound to the host in subdomain mode.
Ubuntu/Debian (UFW):
sudo ufw allow 80/tcp # ACME HTTP-01 challenge + redirect to 443
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow 443/udp # HTTP/3 (QUIC) โ Caddy serves it by default
sudo ufw allow 22/tcp # SSH (don't forget this BEFORE enabling the firewall)
sudo ufw enable
sudo ufw status numbered
RHEL/CentOS/Fedora (firewalld):
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-port=443/udp
sudo firewall-cmd --reload
Cloud-provider firewall (EC2, DigitalOcean, etc.): open inbound 80, 443/tcp, 443/udp from 0.0.0.0/0. The host-level UFW/firewalld step is still recommended as defense-in-depth.
Check from outside the host (a different machine):
curl -fsSL https://walt-issuer.example.com/.well-known/openid-credential-issuer | head
Returns the walt.id metadata document โ routing works. Fails with a TLS error โ Let's Encrypt didn't complete (usually port 80 unreachable, DNS not propagated, or a rate-limit hit on Let's Encrypt โ see docker logs caddy-public for specifics).
Using a different reverse proxy
If you'd rather use nginx, Traefik, AWS ALB, Cloudflare Tunnel, etc.: skip the subdomain profile (just don't set VERIFIABLY_HOSTS_PATTERN's companion VERIFIABLY_PUBLIC_DOMAIN and add --profile="") and route from your own proxy to the per-service container ports. The full upstream map is in Caddyfile.public if you need a reference for what each subdomain should target.
Picking your subdomain scheme
The pattern is printf-style, so you have full control over what each subdomain looks like:
| Pattern | Example URL |
|---|---|
https://%s.example.com |
https://walt-issuer.example.com |
https://%s.demo.example.com |
https://walt-issuer.demo.example.com |
https://verifiably-%s.example.com |
https://verifiably-walt-issuer.example.com |
http://%s.localdev:8080 |
http://walt-issuer.localdev:8080 |
All work. Just make sure your DNS + reverse-proxy match the pattern.
Caveats specific to this mode
- Verifiably-go itself stays backend-agnostic. The same binary runs in either mode; the URL choice happens at deploy-time when
backends.json+auth-providers.jsonare generated. - OIDC redirect URIs auto-configure.
bootstrap-keycloak.shandbootstrap-wso2is.shconsumeVERIFIABLY_CALLBACK_URL(set automatically bydeploy.shviaurl_for), so the pattern-mode callback (https://verifiably.<your-domain>/auth/callback) lands in both clients' allow-lists alongside the legacy localhost entries. Re-running./deploy.sh up <scenario>after a domain change is enough โ both bootstraps are idempotent and additive (set-union, not replace), so a host change doesn't strand stale entries. - eSignet redirect URI.
repair_injiweb_client_redirect_uriindeploy.shinjectshttp://${PUBLIC_HOST}:3004/redirectinto thewallet-demo-clientrow in eSignet's postgres โ still pattern-unaware. After first deploy in pattern mode, manually run:
insideUPDATE client_detail SET redirect_uris = redirect_uris || ARRAY['https://inji-web.<your-domain>/redirect'] WHERE id = 'wallet-demo-client';injiweb-postgres, or fall back toVERIFIABLY_PUBLIC_HOSTmode for the eSignet-fronted Inji Web flow specifically.
Full reset
To start from a fully clean slate (wipe all keys, all registered clients,
all issued VCs):
./deploy.sh down all
docker compose -p waltid --profile injiweb \
-f deploy/compose/stack/docker-compose.yml \
rm -f -v certify-postgres inji-certify \
certify-preauth-postgres inji-certify-preauth-backend inji-preauth-proxy \
certify-nginx certify-preauth-nginx \
injeweb-postgres injiweb-esignet \
injiweb-mimoto injiweb-ui injiweb-oidc-ui \
injiweb-mock-identity injiweb-datashare injiweb-minio \
injiweb-redis
docker volume rm waltid_certify-db waltid_certify-pkcs12 \
waltid_certify-preauth-db waltid_certify-preauth-pkcs12 \
waltid_injiweb-db waltid_injiweb-esignet-keystore \
waltid_injiweb-mockid-keystore waltid_injiweb-minio
./deploy.sh up all && ./deploy.sh run all
This is what you want when Inji Certify's keys drift out of sync with
already-issued status-list credentials (see dpg-matrix.md ยง Inji Certify).
Kubernetes / Helm
Every runtime piece already ships as a container, so porting to Helm is
mostly mechanical:
- The
host.docker.internal:host-gatewaytrick the inji-proxy depends on
becomes a proper KubernetesServiceDNS name โ certify-nginx's
upstream stanza points atverifiably-go.<namespace>.svcinstead. - Persistent docker volumes become
PersistentVolumeClaims. - Seed scripts become
Jobs orinitContainers that gate the main
services' readiness probes. - Secrets (the
wallet-demo-clientp12 password, OIDC client secrets)
move intoSecretresources. - The translator cache can stay inside the verifiably-go pod or move to
a shared PVC if you scale horizontally.