Self-Hosted Overview
Run transmute in your own infrastructure under an offline licence: what the licence controls, which image to pick, a minimal Helm install and every environment variable a self-hosted deployment reads.
What a self-hosted install is
A self-hosted (on-premises) install runs the same API you call at api.transmute.403fin.io, inside your own network. It consists of three things:
| Piece | What it is | Where it comes from |
|---|---|---|
| Container image | One image carrying four binaries: /api (the default entrypoint), /worker, /connector and /admin | ghcr.io/403ent/transmute:<release>, or a docker save tarball for air-gapped sites |
| Helm chart (optional) | A Kubernetes chart released alongside each image tag | Delivered with your release |
| Licence file | A signed token naming your organisation, plan, enabled features and expiry | Issued to you by 403 Finance, delivered separately |
You also provide a PostgreSQL 16+ database. It holds metadata only (tenants, API key hashes, usage counters, batch job state), never message bodies. The API applies its own schema migrations at startup; there is no separate migration step. Redis is optional and only needed when you run more than one API replica (Scale out).
Always pin a release tag. Never deploy latest.
Licence mode
Setting TRANSMUTE_LICENSE switches the server into licence mode. The licence is an Ed25519-signed token, verified at startup against a public key built into the release image. Verification is entirely offline: the server makes no network call to check it, and no binary in the image reports usage, phones home or checks for updates. Air-gapped installs are fully supported.
TRANSMUTE_LICENSE accepts either the token itself or, when the value starts with /, the path to a file containing it. Mount the file read-only and point the variable at it; the token string is never logged (only its id, organisation, plan, features and expiry are).
What licence mode changes compared with the hosted service:
| Area | Behaviour under a licence |
|---|---|
| API keys, scopes, rate limits | Unchanged. Every request still needs a key; you create them with /admin |
| Monthly quota | Unlimited. X-Quota-Remaining never counts down |
| Billing | None. No payment provider is contacted and no billing routes exist |
| Usage records | Still written to your own Postgres, metadata only, for your visibility |
| Optional features | Enabled by the licence's feature list (below) |
Expiry
| When | Behaviour |
|---|---|
| More than 30 days before expiry | Normal operation |
| Within 30 days of expiry | Runs normally; logs one warning per day |
| Expired, inside the grace window | Still runs; logs one error per day with the grace end date |
| Expired beyond grace | Refuses to start |
The check runs at startup. A process that crosses the grace end while running keeps serving until its next restart, then refuses. Renew before expiry: replace the licence file and restart. A malformed or tampered licence also stops the server at startup; it never falls back to unlicensed behaviour. There is no remote revocation, so treat the licence file as confidential.
Feature gates
Core conversion, normalization, validation (syntax, schema) and GET /v1/formats are never gated. A licence issued without a feature list enables everything; a licence with a list enables exactly the listed features:
| Feature | Unlocks |
|---|---|
batch | POST /v1/convert/batch, /v1/jobs/* and the job worker |
webhooks | /v1/webhooks |
enrich | POST /v1/enrich |
cbpr, hvps, fedwire, sepa, cgi, network | The matching level= value on POST /v1/validate |
connections | /v1/connections and batch delivery to a connection |
transactions | Posting accounting transactions through a connection (requires connections) |
overrides | Mapping overrides, including /v1/overrides and TRANSMUTE_MAPPING_OVERRIDES |
404 versus 503
The two status codes tell you different things, so you can tell a licensing gap from a configuration gap:
- Not licensed → 404. A feature that is not in your licence does not exist in the deployment: its routes are never mounted and answer
not-found. An unlicensed validation level is rejected like any unknown level, withinvalid-input400, andGET /v1/formatslists only the levels your deployment serves. - Licensed but misconfigured → 503. A feature you are licensed for that is missing its configuration answers 503. For example, batch without
TRANSMUTE_JOB_KEYanswersbatch-unavailable, and connections without a job key answer 503 as well. The synchronous API keeps working.
Images
Every release publishes two images from the same commit:
| Image | Tag | Runtime | XSD validation engine |
|---|---|---|---|
| Default | <release> | Distroless static, pure Go, no C code | structural |
| Full XSD | <release>-xsd | Debian slim with libxml2 (built with cgo) | libxml2 |
The default engine checks well-formedness, required elements, key enumerations, length limits and value patterns. The -xsd flavour validates MX messages against the full ISO 20022 schemas, including element order and cardinality, at the schema level of POST /v1/validate (and at cbpr, which runs schema first). Conversion output is identical on both images; only validation differs. GET /v1/version reports schemaEngine, and validation responses report meta.schemaEngine.
Trade-offs of the -xsd image:
- It is larger (around 200 MB) and carries libxml2, a C library with its own CVE history. Stay current with releases to receive patched builds. Documents are pre-screened in Go before libxml2 sees them: DTDs are rejected, and network access and external entities are off.
- Schema validation runs one document at a time per replica. Single validations take milliseconds, but a validation-heavy workload needs more replicas rather than more CPU per pod.
Both images run as uid/gid 65532 with the same entrypoint, port (8080) and binary paths.
Install with Helm
Create the licence and database Secrets, then install with a minimal values file. Secret values never go in the values file: the chart references existing Secrets by name.
kubectl create namespace transmute
kubectl -n transmute create secret generic transmute-license \
--from-file=license=./your-org.license
kubectl -n transmute create secret generic transmute-db \
--from-file=DATABASE_URL=./database-url.txt
# values.yaml
image:
repository: ghcr.io/403ent/transmute
tag: <release> # pinned; never latest
flavor: default # or xsd for the full-XSD image
license:
existingSecret: transmute-license
secretKey: license # mounted as a file; TRANSMUTE_LICENSE points at it
externalDatabase:
existingSecret: transmute-db
secretKey: DATABASE_URL
helm install transmute ./transmute -n transmute -f values.yaml
Then create a tenant and an API key with the admin CLI inside the API pod. The full key is printed once; only its hash is stored.
kubectl -n transmute exec deploy/transmute -- /admin create-tenant --name "Example Org"
kubectl -n transmute exec deploy/transmute -- /admin create-key --tenant <tenant-uuid> --name ops
Other chart values worth knowing: jobKey.existingSecret (enables batch, webhooks and connections), oauth.existingSecret (OAuth client credentials), redis.enabled and replicaCount (Scale out), worker.enabled (Standalone worker), connector.* (File-drop connector), mappingPack.* (Mapping packs), ingress.mtls.* (Mutual TLS), trustedProxies, enrich.* and extraEnv. The chart's liveness and readiness probes call GET /v1/health; GET /v1/ready additionally checks the database.
Docker Compose works the same way: run the image with the variables below in an env_file (mode 600, never committed) and the licence bind-mounted read-only.
Environment variables
Set secrets through the environment (from a Kubernetes Secret or a protected env file), never in config files or chart values. Every variable below is read by the API process.
| Var | Required | Meaning |
|---|---|---|
TRANSMUTE_LICENSE | Yes, for licence mode | The licence token, or a path starting with / to a file containing it |
DATABASE_URL | Yes | Postgres DSN. A secret (it carries the password). Unset means an unauthenticated development mode; configured but unreachable stops startup |
TRANSMUTE_ENV | Recommended | Set to production to make startup fail if DATABASE_URL is missing, instead of serving the unauthenticated development mode |
PORT | No (default 8080) | Listen port |
LOG_LEVEL | No (default info) | debug for debug logs. Logs never contain message content |
TRANSMUTE_JOB_KEY | For batch, webhooks, connections | 32-byte AES-256 key as 64 hex characters or standard base64. Encrypts batch payloads and connection tokens at rest. Generate with openssl rand -hex 32 |
TRANSMUTE_JOB_KEY_PREVIOUS | No | The previous job key, kept during a key rotation so existing jobs stay readable |
TRANSMUTE_OAUTH_SIGNING_KEY | For OAuth tokens | Base64 32-byte Ed25519 seed. Unset leaves /v1/oauth/token and the JWKS route unmounted; API keys still work |
TRANSMUTE_EMBEDDED_WORKER | No (default on) | false stops the API from running batch jobs itself; see Standalone worker |
TRANSMUTE_WORKER_CONCURRENCY | No (default 1) | Items the embedded worker processes at once |
REDIS_URL | For more than one replica | redis:// or rediss:// URL; see Scale out |
TRANSMUTE_MAPPING_PACK | No | Path to a signed mapping pack; see Mapping packs |
TRANSMUTE_MAPPING_OVERRIDES | No | Directory of override/v1 documents applied to all tenants, loaded at startup (needs the overrides feature). See Mapping overrides |
TRANSMUTE_TLS_CERT, TRANSMUTE_TLS_KEY, TRANSMUTE_TLS_CLIENT_CA | No | Serve TLS, and optionally require client certificates; see Mutual TLS |
TRANSMUTE_TRUSTED_PROXY | No | Comma-separated CIDRs of proxies allowed to set forwarding headers. Needed for IP allowlists behind an ingress. Invalid values stop startup |
TRANSMUTE_BASE_URL | No | Public base URL, used to build the OAuth redirect URI for accounting connections. Unset derives it from each request's Host |
TRANSMUTE_CORS_ORIGINS | No (default off) | Comma-separated exact browser origins allowed to call /v1. Unset sends no CORS headers |
TRANSMUTE_MAX_BODY_BYTES | No (default 2 MiB) | Request body limit |
TRANSMUTE_MAX_INFLIGHT | No (default 256) | Concurrent conversion/validation requests before excess is shed with 429. 0 or less disables the limit |
TRANSMUTE_BIC_DIRECTORY, TRANSMUTE_ENRICH_LEI_MAP, TRANSMUTE_ENRICH_ABA_DIR, TRANSMUTE_ENRICH_BLZ_FILE | No | Reference files you supply for enrichment. A named file that is missing or malformed stops startup |
TRANSMUTE_XERO_CLIENT_ID / _SECRET, TRANSMUTE_QBO_CLIENT_ID / _SECRET, TRANSMUTE_SAGE_CLIENT_ID / _SECRET | For accounting connections | Your OAuth app credentials for each provider. A provider without credentials is not offered |
OTEL_EXPORTER_OTLP_ENDPOINT | No (default off) | Enables OpenTelemetry tracing over OTLP/HTTP. Spans carry route, method, status and size only. The standard OTEL_EXPORTER_OTLP_* variables and OTEL_SDK_DISABLED are honoured |
GET /metrics serves a Prometheus exposition of request metadata (no message content) without authentication, so keep it off any public listener.
Variables used only by the hosted service (billing, the customer portal, its email and bot-check providers and its edge infrastructure) do nothing on a self-hosted install; leave them unset. The vendor-side licence signing key is never set on, or needed by, your systems.
Health endpoints
| Endpoint | Answers |
|---|---|
GET /v1/health | 200 {"status":"ok"} while the process is up |
GET /v1/ready | 200 when the database answers, 503 {"status":"degraded"} when it does not. Also reports Redis and the loaded mapping pack |
GET /v1/version | Release version, commit and schemaEngine |