Deploying without Kubernetes¶
The Helm chart is a convenience, not a requirement. The image is an ordinary Spring Boot application, so every setting it reads can be supplied as an environment variable, and anything awkward to express that way can be supplied as a configuration file the application is told to read. That is enough to run it on plain Docker, on a container platform such as ECS or Cloud Run, on Nomad, or from a systemd unit.
This guide is the environment-only contract: what must be set, how property names map to variable names, how to supply the settings that are nested lists, and what the image's shape forces you to do differently.
For Kubernetes, use Deploying on Kubernetes instead — the chart already does everything below.
Prerequisites¶
| What | Why |
|---|---|
| A PostgreSQL database | Snapshots, the audit ledger, tokens and grants live there. Nothing here creates one. |
| An OIDC client | The whole web surface authenticates with OIDC. Client id, client secret, the three endpoint URIs and the provider's issuer — see Identity providers. The gateway refuses to start with a provider configured and no issuer. |
| Persistent storage | On the default filesystem backend, the repositories live on disk under the data directory and must outlive the container. See Storage. |
| A container memory limit | The image runs a JVM, and its heap ceiling is a percentage of the limit. With no limit set that percentage is taken from the host's memory. See Memory. |
Writable scratch at /tmp |
Only if you run with a read-only root filesystem — Tomcat's work directory lives there and the process will not start without it. See A writable /tmp. |
| A TLS-terminating proxy | The application speaks plain HTTP on 8080. See Running behind a proxy — this needs one setting, and logins fail without it. |
Name an administrator, or the process will not start
Authorization is always enforced, and a gateway whose configuration grants
the admin role to nobody refuses to start rather than run as an estate
nobody can administer. Name one in skills-gateway.roles.admins, in a
skills-gateway.roles.mappings entry resolving admin, or in a declared
skills-gateway.estate.grants entry. See
Delegated administration.
The minimum environment¶
# Database
SPRING_DATASOURCE_URL=jdbc:postgresql://postgres.example.com:5432/skillsgateway?sslmode=require
SPRING_DATASOURCE_USERNAME=skillsgateway
SPRING_DATASOURCE_PASSWORD=… # from your secret store
# OIDC — see Identity providers for where these come from
SGW_OIDC_CLIENT_ID=…
SGW_OIDC_CLIENT_SECRET=… # from your secret store
SGW_OIDC_AUTHORIZATION_URI=https://idp.example.com/oauth2/v2.0/authorize
SGW_OIDC_TOKEN_URI=https://idp.example.com/oauth2/v2.0/token
SGW_OIDC_JWK_SET_URI=https://idp.example.com/discovery/v2.0/keys
SGW_OIDC_USER_NAME_ATTRIBUTE=preferred_username
SGW_OIDC_SCOPE=openid,profile,email
SKILLSGATEWAY_OIDC_ISSUER=https://idp.example.com/v2.0 # required with a provider configured
# Behind a TLS-terminating proxy — see Running behind a proxy
SERVER_FORWARDHEADERSSTRATEGY=native
# At least one administrator
SKILLSGATEWAY_ROLES_ADMINS_0=platform-admin@example.com
SKILLSGATEWAY_DATADIR is already /data in the image; override it only if you
mount somewhere else.
Set the OIDC endpoints, not only an issuer
The application.yaml baked into the image carries placeholder provider
endpoints (https://idp.invalid/…). Spring applies explicitly configured
provider URIs on top of anything discovered from an issuer, so setting
only spring.security.oauth2.client.provider.idp.issuer-uri leaves the
placeholders in force and no login can complete. Set the three SGW_OIDC_*
endpoint variables above. Doing so also removes a discovery call from the
startup path, which is one less thing to be reachable at boot.
Running behind a proxy¶
The application serves plain HTTP on port 8080 and terminates no TLS. Whatever
sits in front of it — a load balancer, an ingress, a reverse proxy — sees
https://<your-host>; the application sees http://<container>:8080. Every
URL it builds from the request is wrong until it is told what the proxy sees,
and the proxy says so in X-Forwarded-Proto and X-Forwarded-Host:
Without it the OIDC redirect URI arrives at the identity provider as
http://<container>:8080/login/oauth2/code/idp rather than the
https://<your-host>/login/oauth2/code/idp you registered, and every login
fails on a redirect-URI mismatch — reported by the provider against its own
application registration, not against the gateway. Set it wherever TLS
terminates somewhere other than the application itself, which in practice is
everywhere.
The setting is a trust decision, because the application cannot tell a proxy's header from a client's. Its three values differ in whom they believe:
| Value | Who is believed | When |
|---|---|---|
native |
Tomcat honours the headers only from a peer in a private address range: 10/8, 172.16/12, 192.168/16, 100.64/10, loopback, and their IPv6 counterparts. |
The proxy is in your VPC or cluster — where a cloud load balancer or an ingress controller sits. The recommended value. |
framework |
Spring's filter honours the headers from any peer. | The proxy has a public address, or you need X-Forwarded-Prefix or RFC 7239 Forwarded. Only where nothing but the proxy can reach port 8080, and where the proxy overwrites the headers rather than passing a client's through. |
none |
Nobody. The container's own view of the request. | The application terminates TLS itself. |
A proxy outside the private ranges can be named rather than trusting everyone:
server.tomcat.remoteip.internal-proxies (SERVER_TOMCAT_REMOTEIP_INTERNALPROXIES)
replaces the list above, as CIDRs or a regular expression, and native then
believes those peers.
Leaving the variable unset is not the same as none: Spring Boot then deduces
native on a container platform it recognises from the environment —
Kubernetes and ECS among them — and none anywhere else. Name the value; a
deployment that works because of a deduction is one nobody can read.
framework was inert on the released image until it was registered explicitly
Spring Boot registers its forwarded-header filter behind a condition that an
ahead-of-time image evaluates once, when the image is built, and the property
is not set then — so SERVER_FORWARDHEADERSSTRATEGY=framework did nothing on
the GraalVM native image the release used to publish, however correctly it
was deployed. The gateway now registers the filter itself and reads the
setting at runtime, so every value means the same on every packaging. Do not
go back to relying on Spring Boot's own registration: the released image is a
JVM container today, but the explicit registration is what makes the setting
mean one thing regardless.
The redirect URI can also be stated outright, with no header trusted at all:
It fixes the login and nothing else — every other URL built from the request still names the container — so it is the escape hatch, not the fix.
The proxy's timeout¶
A first ingest of a repository with a long history runs inside one request for
minutes (why). An AWS Application Load Balancer's
idle timeout and nginx's proxy_read_timeout both default to 60 seconds; raise
them, to 600 seconds for example. On ECS behind an ALB that is the load
balancer attribute idle_timeout.timeout_seconds. A timeout that is too short
loses only the response: the ingest still finishes and is recorded on the
marketplace.
Property names as environment variables¶
Spring's relaxed binding maps a property to a variable by upper-casing it,
replacing . with _, and removing hyphens:
| Property | Environment variable |
|---|---|
skills-gateway.data-dir |
SKILLSGATEWAY_DATADIR |
skills-gateway.roles.claim |
SKILLSGATEWAY_ROLES_CLAIM |
skills-gateway.storage.backend |
SKILLSGATEWAY_STORAGE_BACKEND |
skills-gateway.retention.enabled |
SKILLSGATEWAY_RETENTION_ENABLED |
server.forward-headers-strategy |
SERVER_FORWARDHEADERSSTRATEGY |
Hyphens are removed, not turned into underscores: data-dir becomes DATADIR,
never DATA_DIR. Every setting in Configuration
is reachable this way.
Lists¶
A list of scalars is indexed:
SKILLSGATEWAY_ROLES_ADMINS_0=first-admin@example.com
SKILLSGATEWAY_ROLES_ADMINS_1=second-admin@example.com
A list of objects indexes each field:
SKILLSGATEWAY_ROLES_MAPPINGS_0_CLAIMVALUE=SkillsGateway.Admin
SKILLSGATEWAY_ROLES_MAPPINGS_0_ROLE=admin
SKILLSGATEWAY_ESTATE_MARKETPLACES_0_NAME=corp-marketplace
SKILLSGATEWAY_ESTATE_MARKETPLACES_0_URL=https://git.example.com/skills/corp.git
This works, and for one or two entries it is the least machinery. It stops being reasonable somewhere around the declarative estate, which is nested lists of objects and reads as line noise in this form. For that, supply a file.
Supplying a configuration file¶
The chart mounts a ConfigMap and points SPRING_CONFIG_ADDITIONAL_LOCATION at
it. Outside Kubernetes the destination is the same; only the delivery differs.
The trailing slash matters: it names a directory, and Spring reads
application.yaml from it. The file is layered over the application.yaml
baked into the image, so it carries only what you are overriding.
The image has no shell
It is distroless: no shell, no sh -c, no curl, no package manager.
Nothing inside the application container can write the file, template it,
or fetch it before start. Whatever produces the file has to run somewhere
else — the host, a volume populated ahead of time, or a separate container.
Three ways to get the file there, in rough order of how much machinery they add:
Simplest where you control the filesystem: write application.yaml next to
your deployment definition and mount its directory read-only. This is the
plain-Docker and systemd answer.
On a container platform with no host filesystem to mount, run a small utility image as an init container that writes the file into a volume shared with the application container, and order the application to start after it completes. This is the ConfigMap pattern rebuilt from parts: the content can come from the deployment definition itself, or the init container can fetch it from a secret store or an object store.
A single environment variable carrying the whole nested structure as JSON:
SPRING_APPLICATION_JSON='{"skills-gateway":{"roles":{"claim":"roles","mappings":[{"claim-value":"SkillsGateway.Admin","role":"admin"}]}}}'
No file and no second container, at the cost of a configuration that no longer diffs readably. Reasonable for a handful of nested settings, poor for a large estate.
Whichever you choose, the file is not a secret store. A client secret, a webhook secret or an audit-sink credential belongs in an environment variable sourced from your platform's secret manager, referenced from the declaration as described in Declarative estate configuration.
Storage¶
On the default filesystem backend the quarantine, the published content the
facade serves, and any hosted marketplaces all live under the data directory —
/data in the image. PostgreSQL records which snapshots exist and which are
approved, so the volume and the database are one estate: a gateway that restarts
onto an empty /data still reports its snapshots as published and can serve
none of them. Give it storage that outlives the container.
It also means exactly one instance. The filesystem backend has no cross-process locking, so two processes on one volume can interleave a fetch and a publish into a corrupt repository. That is not a supported configuration, and a rolling deploy that overlaps old and new is two processes. Configure your platform for stop-then-start rather than an overlapping replacement.
Running more than one instance means the object-store backend — see
Choosing and migrating the storage backend. Nothing else
has to be turned off to do it: the scheduled background passes coordinate
themselves, each taking a lease so that it runs once per interval across the
whole estate rather than once per instance.
A writable /tmp, if you seal the root filesystem¶
Sealing the container's root filesystem is worth doing, and this image is built
for it — but the application still needs one writable scratch directory.
Tomcat creates its work directory under java.io.tmpdir while the web server
starts, so with nowhere to write it the process exits before serving anything:
Unable to create tempDir. java.io.tmpdir is set to /tmp
Caused by: java.nio.file.FileSystemException:
/tmp/tomcat.8080.13526778125704680643: Read-only file system
Mount ephemeral scratch at /tmp. Nothing durable belongs there — it is
discarded with the container, and losing it costs nothing.
The mount must be writable by uid 65532, not just present
The image runs as the distroless nonroot user. Several container
platforms mount an empty volume root-owned, which leaves /tmp
present and unwritable — and that fails with exactly the error above, so
a half-configured mount is indistinguishable from no mount at all.
Kubernetes handles this through fsGroup in the pod security context (the
chart sets it). Elsewhere, chown the directory before the application
starts — on a platform with init containers, that is what they are for.
The object-store backend needs the same scratch, and one thing more: its
local pack cache defaults to {data-dir}/object-store-cache. Where you run
with no durable volume at all, point skills-gateway.storage.object-store.cache.dir
at a path under your ephemeral mount — nothing in that cache is authoritative,
and deleting it at any moment is safe.
The embedded git library (JGit) also keeps a small cache of its own under
$XDG_CONFIG_HOME — a measured filesystem-timestamp attribute, written the
first time it touches a repository. Left unset, alongside a HOME this user
does not have, JGit falls back to a home directory that does not exist here
either, and fails to create it on every fetch: caught and logged, not fatal,
but the log line drowns out anything that is. The image already sets
XDG_CONFIG_HOME=/tmp/xdg-config, so nothing further is needed once /tmp
itself is writable — the same mount this section is about.
Memory¶
The image runs the application jar on a JVM, and its entrypoint sets
-XX:MaxRAMPercentage=75.0: the heap may grow to three quarters of the
container's memory limit. Under a 1 GiB limit the process sat at roughly 255 MB
resident while idle; the term that is not in that number is JGit packing a large
upstream repository, which is what the remaining headroom is for.
Set a limit. The percentage is taken from whatever the JVM believes the container has, so an unlimited container gives the heap three quarters of the host — which is only a problem the first time something else on that host needs the memory.
Health checks¶
/actuator/health on port 8080 is the readiness and liveness signal, and it is
the only unauthenticated path. Probe the bare path, not the /readiness and
/liveness subpaths.
Because the image has no shell and no HTTP client, a container-level health check — the kind that runs a command inside the container — cannot be written. The probe has to come from outside: your load balancer's target health check, your platform's HTTP probe, or an external monitor.
Metrics and traces¶
The health endpoint is the only thing the gateway serves about itself. Its metrics (ingestion, approvals, facade fetches, ledger export lag) are pushed over OTLP and only once export is switched on:
ARCONIA_OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SERVICE_NAME=skills-gateway
On ECS the endpoint is typically an OpenTelemetry collector sidecar in the same task. The metric names, and the two worth alerting on, are in Observability.
A worked example: plain Docker¶
docker volume create skills-gateway-data
docker run -d --name skills-gateway \
-p 8080:8080 \
-v skills-gateway-data:/data \
-v "$PWD/config:/config:ro" \
--read-only \
--mount type=tmpfs,destination=/tmp,tmpfs-mode=1777 \
-e SPRING_CONFIG_ADDITIONAL_LOCATION=optional:file:/config/ \
-e SPRING_DATASOURCE_URL='jdbc:postgresql://postgres.example.com:5432/skillsgateway?sslmode=require' \
-e SPRING_DATASOURCE_USERNAME=skillsgateway \
-e SPRING_DATASOURCE_PASSWORD="$DB_PASSWORD" \
-e SGW_OIDC_CLIENT_ID="$OIDC_CLIENT_ID" \
-e SGW_OIDC_CLIENT_SECRET="$OIDC_CLIENT_SECRET" \
-e SGW_OIDC_AUTHORIZATION_URI=https://idp.example.com/oauth2/v2.0/authorize \
-e SGW_OIDC_TOKEN_URI=https://idp.example.com/oauth2/v2.0/token \
-e SGW_OIDC_JWK_SET_URI=https://idp.example.com/discovery/v2.0/keys \
-e SGW_OIDC_USER_NAME_ATTRIBUTE=preferred_username \
-e SGW_OIDC_SCOPE=openid,profile,email \
-e SKILLSGATEWAY_OIDC_ISSUER=https://idp.example.com/v2.0 \
-e SERVER_FORWARDHEADERSSTRATEGY=native \
ghcr.io/skillsgateway/skillsgateway:<released-version>
With config/application.yaml:
skills-gateway:
roles:
admins:
- platform-admin@example.com
claim: roles
mappings:
- claim-value: SkillsGateway.Admin
role: admin
estate:
marketplaces:
- name: corp-marketplace
url: https://git.example.com/skills/corp.git
--mount, not --tmpfs, if you might be on Podman
Docker accepts --tmpfs /tmp:uid=65532,gid=65532; Podman 6.1 rejects
it with unknown mount option "uid=65532". The --mount
type=tmpfs,…,tmpfs-mode=1777 form above works on both, and the sticky
1777 mode makes it writable by the image's uid without naming it —
which is also one less thing to update if that uid ever changes.
--read-only with a tmpfs at /tmp is the shape worth copying: the root
filesystem is sealed, /data holds the estate, /config is mounted read-only,
and scratch is a world-writable tmpfs the nonroot uid can use. Drop the
--read-only and the tmpfs becomes unnecessary — but so does most of the point
of a distroless image.
Pin a released version or a digest rather than a moving tag — see Container image.
Egress¶
Two outbound destinations matter where egress is filtered:
| Destination | Used for | Required |
|---|---|---|
| Each marketplace's git host, over HTTPS | Ingesting and syncing upstream content, as git smart HTTP | Yes — ingestion is the product |
| The forge's REST API host | Best-effort project name, description and last-update at registration | No — any failure resolves to empty and registration continues |
| Your identity provider | The OIDC token and JWKS endpoints | Yes |
Ingestion is a plain clone and fetch, so a rule for the git host is what it
needs — github.com, not api.github.com, and no archive host: nothing fetches
tarballs. Registration lists the upstream before it creates anything, so a
missing rule for the git host already shows at registration, as a refusal that
names the unreachable upstream.
If your manifests declare external plugin sources,
those clone URLs are additional destinations. They are refused by default;
leaving skills-gateway.ingestion.allow-external-sources unset is what keeps
the outbound set bounded to the marketplaces you registered.
Backups and upgrades¶
The database and the git storage are one estate and have to be backed up and restored together — see Backing up, restoring and upgrading, which also covers what startup does to the schema and what a rollout does to an in-flight fetch.