Thorn Tech Marketing Ad
Skip to main content
Version: Next

Container Deployment Reference

TLDR

What: Reference for StorageLink container deployments: images, ports, variables, TLS, networking, security context and troubleshooting

Components: storagelink-backend (Spring Boot API, port 8080), storagelink-ui (Nginx admin UI, HTTPS on 8443) and PostgreSQL with the ltree extension; both images are public on Docker Hub (thorntech/storagelink-backend, thorntech/storagelink-ui)

Required Variables: Backend SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD and SECURITY_JWT_SECRET; UI BACKEND_URL; both use the same SECURITY_CLIENT_ID and SECURITY_CLIENT_SECRET

Licensing: Set LICENSE_CONTENT (the whole license JSON) for unattended deployments, or start the 30-day trial or activate a license under Settings → License after the first sign-in

Important:

  • Never expose the backend port; keep its Kubernetes Service ClusterIP
  • Anyone who reaches the UI first can create the admin account, so set it at deploy time or restrict access
  • For local file storage, use an absolute base prefix such as /mnt/data
  • Behind an ingress, StorageLink needs the root of its own hostname; health-check /index.html

Product: StorageLink by Thorn Technologies — cloud storage gateway for secure file sharing

This is the comprehensive reference for deploying StorageLink as containers. It covers every configuration option, security context, networking, TLS, and troubleshooting.

Looking for a step-by-step deployment guide? See the cloud-specific recipes:

CloudHelm ChartKubernetes Manifests
AWSDeploy with Helm on EKSDeploy on EKS with K8s Manifests
AzureDeploy with Helm on AKSDeploy on AKS with K8s Manifests
GCPDeploy with Helm on GKEDeploy on GKE with K8s Manifests

Components​

StorageLink consists of two container images that work together:

ImageDescription
storagelink-backendCore API server (Spring Boot). Handles cloud storage operations and user management.
storagelink-uiWeb-based administration dashboard (Nginx). Provides the management interface for configuring storage connections and managing users.

A PostgreSQL database (16 or later) is also required.

Image Access​

Both images are public on Docker Hub:

ImageDocker Hub
thorntech/storagelink-backend:1.3.1hub.docker.com/r/thorntech/storagelink-backend
thorntech/storagelink-ui:1.3.1hub.docker.com/r/thorntech/storagelink-ui

No registry credentials are needed. Use the same version tag for both images. To run from your own registry, mirror both images and change the image references.

Ports​

Backend​

PortProtocolDescription
8080TCPREST API (HTTP)

UI​

PortProtocolDescription
8080TCPHTTP (used when EXTERNAL_TLS_TERMINATION=true)
8443TCPHTTPS (default, in-pod TLS termination)

Configuration​

Backend Environment Variables​

VariableDescriptionRequired
SPRING_DATASOURCE_URLPostgreSQL JDBC URL (e.g., jdbc:postgresql://db:5432/swift_gw?sslmode=require)Yes
SPRING_DATASOURCE_USERNAMEDatabase usernameYes
SPRING_DATASOURCE_PASSWORDDatabase passwordYes
SECURITY_CLIENT_IDOAuth client ID — must match UIYes
SECURITY_CLIENT_SECRETOAuth client secret — must match UIYes
SECURITY_JWT_SECRETJWT signing secret, at least 32 characters (openssl rand -base64 32). A shorter one starts, but every sign-in failsYes
LICENSE_CONTENTLicense for unattended deployments: the whole license JSON (minified). The backend activates it at startup and binds it to the deployment's database, which needs outbound HTTPS to licensing.thorntech.com; a new or wiped database needs a new license. A license set here takes precedence over one activated in the admin UI. If unset, license the deployment from the admin UI after the first sign-in (Settings → License): start the free 30-day trial with a work email and verification code, or paste the purchased license file and click ACTIVATE. Until a trial or license is active, administrators can configure everything but non-administrator users cannot sign in and file transfers are blocked. See Purchasing a License.No
SERVER_PORTAPI server port (default: 8080)No
SERVER_MAX_HTTP_REQUEST_HEADER_SIZELargest request header accepted (default 8KB). Set 128KB: Microsoft Entra ID tokens exceed 8KB, and OIDC sign-in then fails or works only in a private window. StorageLink 1.4.0 makes 128KB the defaultNo
SPRING_PROFILES_ACTIVESpring profiles (use container,json-logs for containerized deployments)No
FEATURES_INSTANCE_CLOUD_PROVIDERCloud provider hint: azure, aws, or gcpNo
FEATURES_SYSTEMD_NOTIFYSet to false in containers (systemd is not present)No
FEATURES_SFTP_SUBSYSTEM_AUTO_STARTSet to false (StorageLink does not run an in-container SFTP subsystem)No
FEATURES_FIRST_CONNECTION_CLOUD_PROVIDERPre-configure the first storage connection: s3, azureblob, gcp, or lfsNo
FEATURES_FIRST_CONNECTION_BASE_PREFIXBase path or bucket prefix for the first connectionNo
SECURITY_CLIENTIP_TRUSTEDPROXIESTrusted proxy CIDRs for client IP resolution (see Trusted Proxy Configuration)No
JAVA_OPTSJVM tuning options (e.g., -Xms2g -Xmx6g)No

UI Environment Variables​

VariableDescriptionRequired
BACKEND_URLURL to the backend API (e.g., http://backend:8080/)Yes
SECURITY_CLIENT_IDOAuth client ID — must match backendYes
SECURITY_CLIENT_SECRETOAuth client secret — must match backendYes
CLOUD_PROVIDERCloud provider hint for the UINo
WEBSITE_BUNDLE_CRTTLS certificate in PEM format (see TLS Configuration)Conditional
WEBSITE_KEYTLS private key in PEM formatConditional
EXTERNAL_TLS_TERMINATIONSkip in-pod TLS when a load balancer terminates TLS (see TLS Configuration)No

Credential Generation​

Generate the required security credentials before deploying:

SECURITY_CLIENT_ID=$(openssl rand -hex 16)
SECURITY_CLIENT_SECRET=$(openssl rand -hex 16)
SECURITY_JWT_SECRET=$(openssl rand -base64 32)

The client ID and client secret must be identical in both the backend and UI configurations.

Initial Admin Account​

On first launch, StorageLink presents a signup page that allows anyone who can reach the UI to create the initial admin account. Once an admin exists, this signup page is permanently disabled.

Security consideration

If the UI is exposed via a LoadBalancer or public IP without IP restrictions, an unauthorized user could create the admin account before you do. To prevent this, use one of the following approaches:

  1. Pre-configure admin credentials at deploy time (recommended for Helm) — the Helm chart's admin.username and admin.password values trigger a post-install Job that creates the admin automatically.
  2. Restrict network access — use loadBalancerSourceRanges to limit who can reach the UI during initial setup.
  3. Use ClusterIP + port-forward — keep the UI internal and access it via kubectl port-forward for initial configuration.

Helm Chart​

helm install storagelink ./storagelink \
--namespace storagelink --create-namespace \
--set admin.username=myadmin \
--set admin.password='MySecurePassword1!'

The admin password must meet StorageLink's password policy (minimum length, complexity requirements).

Docker Compose / Manual Deployments​

For non-Helm deployments, create the admin account via the backend API immediately after startup:

# Wait for the backend to be ready
until curl -sf http://localhost:8080/actuator/health > /dev/null; do sleep 2; done

# Create the admin account (only works when no admin exists)
curl -X POST http://localhost:8080/1.0.0/admin/config \
-H "Content-Type: application/json" \
-d '{"username": "myadmin", "password": "MySecurePassword1!"}'

The POST /1.0.0/admin/config endpoint returns 201 Created on success and 404 Not Found if an admin already exists.

TLS Configuration​

The UI container supports two TLS modes:

In-Pod TLS (Default)​

The UI terminates TLS internally on port 8443. Provide a certificate and private key via environment variables:

WEBSITE_BUNDLE_CRT="<PEM-encoded certificate>"
WEBSITE_KEY="<PEM-encoded private key>"

If you're using a certificate chain, include all certificates in WEBSITE_BUNDLE_CRT with the server certificate first, followed by intermediate certificates.

To generate a self-signed certificate for testing:

openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=storagelink"

Entrypoint fail-fast behaviors: The UI entrypoint validates TLS configuration at startup:

  • If only one of WEBSITE_BUNDLE_CRT / WEBSITE_KEY is provided (half-delivery), the container exits with an error.
  • If the path points to a directory instead of a file, the container exits with an error.

External TLS Termination​

When a load balancer, ingress controller, or reverse proxy terminates TLS upstream, set:

EXTERNAL_TLS_TERMINATION=true

In this mode, the UI listens on port 8080 (HTTP only) and skips certificate handling. The WEBSITE_BUNDLE_CRT and WEBSITE_KEY variables are not required.

caution

EXTERNAL_TLS_TERMINATION is case-sensitive. Accepted values: true, TRUE, 1, yes. Mixed-case values like True or Yes are not recognized and will silently fall back to in-pod TLS mode.

Backend Port Security​

The backend must only be reachable from within the cluster. Do not expose it via LoadBalancer, NodePort, or a host-port mapping.

The backend trusts the X-Forwarded-For header set by the UI's Nginx reverse proxy to identify client IP addresses for:

  • Per-IP login lockout — rate-limits failed login attempts by source IP
  • Audit logging — records the client's real IP in audit events

If an attacker can reach the backend directly (bypassing the UI), they can inject arbitrary X-Forwarded-For values. This defeats both the lockout mechanism and the integrity of audit logs.

In Docker Compose, do not publish the backend port (no ports: mapping). In Kubernetes, keep the backend Service as ClusterIP.

Client IP Preservation​

StorageLink uses the client's real IP address for login lockout and audit logging. How the client IP reaches the backend depends on your network architecture.

Direct LoadBalancer (Default)​

When the UI Service is type LoadBalancer with externalTrafficPolicy: Local, the cloud load balancer preserves the client's source IP. The UI's Nginx proxy then passes it to the backend via X-Forwarded-For.

spec:
type: LoadBalancer
externalTrafficPolicy: Local

Trade-off: Local only routes to nodes that have a UI pod. If a node has no UI pod, the load balancer skips it — this is why maxSurge: 1 and maxUnavailable: 0 are set on the UI Deployment, ensuring capacity during rolling updates.

Alternative: externalTrafficPolicy: Cluster distributes traffic across all nodes but SNATs (replaces) the source IP. Use this only if you do not need per-IP lockout or accurate audit source IPs.

Behind an Ingress Controller or L7 Load Balancer​

When traffic passes through an ingress controller (e.g., NGINX Ingress, AWS Load Balancer Controller, Azure Application Gateway) or a cloud L7 load balancer before reaching the UI pods, the ingress layer must:

  1. Serve StorageLink at the root of its own hostname — route all of / on a hostname such as storagelink.example.com to the UI. A path prefix such as example.com/storagelink is not supported: the Admin UI requests its scripts and API calls from /, so behind a prefix the page stays blank.
  2. Forward over HTTPS — The UI expects HTTPS on port 8443 by default. If you set EXTERNAL_TLS_TERMINATION=true, the UI listens on port 8080 (HTTP) instead.
  3. Health-check /index.html — it returns 200 in both TLS modes. Do not check /: with EXTERNAL_TLS_TERMINATION=true it redirects to https://<host>/index.html, and the health check fails.
  4. Preserve the Host header — StorageLink uses the Host header for generating download and preview links. If the ingress rewrites the Host header, links will be broken.
  5. Set X-Forwarded-For — Most ingress controllers do this by default.

Example: TLS terminated at the ingress​

This example terminates TLS at the ingress controller and forwards plain HTTP to the UI. Adjust the ingress class, hostname, TLS Secret and any controller-specific annotations for your environment.

With the Helm chart, set:

helm install storagelink ./storagelink \
--namespace storagelink \
--set ui.externalTlsTermination=true \
--set ui.service.type=ClusterIP \
--set ui.service.port=80 \
# ... other values

The chart then runs the UI on HTTP port 8080 behind a ClusterIP Service (storagelink-ui for the release name storagelink, Service port 80) and points the UI's own probes at /index.html.

With the Kubernetes manifests, make these changes:

  • configmap.yaml: add EXTERNAL_TLS_TERMINATION: "true" to storagelink-ui-config.
  • ui.yaml: in the ui Service, set type: ClusterIP, remove externalTrafficPolicy and loadBalancerSourceRanges, and set port: 80 and targetPort: 8080. In the Deployment, set containerPort: 8080, and change both probes to path: /index.html, port: 8080, scheme: HTTP.
  • networkpolicy.yaml: in ui-netpol, allow ingress on port 8080 instead of 8443.

Then create the TLS Secret and the Ingress:

kubectl create secret tls storagelink-tls -n storagelink --cert=tls.crt --key=tls.key
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: storagelink
namespace: storagelink
spec:
ingressClassName: nginx # your ingress controller's class
tls:
- hosts:
- storagelink.example.com
secretName: storagelink-tls
rules:
- host: storagelink.example.com # StorageLink needs the whole hostname
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: storagelink-ui # "ui" with the Kubernetes manifests
port:
number: 80 # the Service port; it forwards to 8080

Put the Service on port 80 in this mode: some ingress controllers, Traefik among them, assume HTTPS for a backend Service on port 443 and return 500 Internal Server Error.

If your load balancer runs its own health check against the pods (for example Azure Application Gateway, AWS ALB target groups or GKE Ingress backend services), set its path to /index.html. To keep per-client IPs in audit records and login lockout, see Trusted Proxy Configuration.

caution

externalTrafficPolicy only works when the Service is the actual entry point for external traffic. When an ingress controller is in the path, the ingress controller's own Service determines IP preservation — not the UI Service. Configure the ingress controller's externalTrafficPolicy instead.

PROXY Protocol​

Some load balancers (e.g., AWS NLB) support PROXY protocol as an alternative to externalTrafficPolicy: Local. PROXY protocol encodes the client IP in a header at the TCP level. If your load balancer uses PROXY protocol, configure the ingress controller to decode it — the UI's Nginx does not natively support PROXY protocol.

Non-Default Ports​

If the UI is served on a non-standard port (anything other than 443), StorageLink may generate download and preview links that include the port number. Ensure your ingress or load balancer passes the correct Host header (including the port) so that generated URLs match the user's browser address.

Trusted Proxy Configuration​

By default, the backend identifies the client IP from X-Forwarded-For using Tomcat's built-in trusted proxy set (RFC 1918 private ranges + loopback). This works with no configuration when the UI is the only hop in front of the backend.

When an L7 load balancer or ingress controller sits in front of the UI, the recorded client IP collapses to the load balancer's address. The application still works — nothing errors — but the audit trail loses per-client attribution and the login lockout applies to the load balancer's IP rather than individual clients.

To restore per-client attribution, register your edge's address ranges:

DeploymentSetting
Container (env var)SECURITY_CLIENTIP_TRUSTEDPROXIES
VM (application.properties)LOAD_BALANCER_ADDRESSES

Use CIDR notation (bare hostnames are not supported):

  • Azure Application Gateway: Use the Application Gateway subnet CIDR (e.g., 10.224.1.0/24). Its instances draw addresses from that subnet and the CIDR is stable.
  • AWS ALB: Use the ALB subnet CIDRs. The ALB itself is a DNS name with rotating addresses, but the subnets you created it in are stable.
  • Cloudflare: Use Cloudflare's published IP ranges, which rotate. Authenticated Origin Pulls (mTLS from Cloudflare to your origin) is an alternative that needs no address list.

This setting is opt-in, not required. A deployment behind an L7 load balancer that doesn't set it still works — it only costs audit-trail precision.

To confirm the setting, sign in as an administrator: the top navigation bar shows Seen as <address>, the client address your own session resolved to. That is the address written to audit records and counted for login lockout.

HTTPS Forwarding to Origin​

When using an L7 load balancer that terminates TLS, the load balancer must forward traffic to the UI over HTTPS on port 443 (the default) or over HTTP on port 8080 with EXTERNAL_TLS_TERMINATION=true.

danger

Do not forward plain HTTP to the UI's default HTTPS port (8443) — Nginx returns a 400 Bad Request for plain HTTP on an SSL listener. Conversely, the VM's port-80 server block has no location for API paths, so forwarding HTTP to port 80 returns 404 with an HTML body. Both failure modes are silent from the load balancer's perspective.

The common L7 pattern of "terminate TLS at the balancer, forward HTTP to the origin" requires setting EXTERNAL_TLS_TERMINATION=true on the UI container. Without it, you must accept double TLS termination (load balancer → HTTPS → UI).

Download and Preview URL Generation​

StorageLink generates absolute URLs for file downloads and previews using X-Forwarded-Proto and X-Forwarded-Port headers. If your front proxy does not set these headers, or sets them incorrectly, download and preview links may point to the wrong protocol or port.

Ensure your ingress controller or load balancer sets:

  • X-Forwarded-Proto — https for TLS-terminated connections
  • X-Forwarded-Host — the original Host header value (most ingress controllers do this by default)

External TLS Pass-Through Mode​

When deploying behind an L7 load balancer with EXTERNAL_TLS_TERMINATION=true, the UI's Nginx template can be configured to pass the client's X-Forwarded-For chain through to the backend instead of overwriting it. This allows the backend to see the original client IP as set by the load balancer.

This mode is only safe when the UI pod is reachable exclusively through the load balancer. If anything else in the cluster can reach the UI Service directly, it can inject a forged X-Forwarded-For chain. To enforce this:

  • Set the UI Service to ClusterIP (not LoadBalancer)
  • Apply a NetworkPolicy restricting ingress to the UI pods to the ingress controller or Application Gateway subnet

Security Context (Kubernetes)​

Both images are built for non-root execution and are compatible with Kubernetes restricted Pod Security Standards.

Backend​

The backend image runs as its own numeric user, UID 1000 with group 0, and /opt/swiftgw is group-0 owned. Do not set runAsUser: leaving it unset keeps the image's user. Set runAsGroup: 0 so the process keeps group 0. The root filesystem is read-only, with emptyDir mounts for writable paths:

securityContext:
runAsGroup: 0
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

Required writable mounts:

Mount PathPurpose
/tmpTemporary files

The backend uses the json-logs profile which writes structured JSON to stdout — no file-based log directories are needed. Each line is tagged application, audit or license-audit, so a log collector can route the three streams separately; license events (activation, replacement, expiry, binding, import caps and rate limiting) never interleave with user file activity. The backend is stateless: all persistent state is stored in PostgreSQL, so no PersistentVolumeClaim is required. This enables horizontal scaling with multiple replicas.

UI​

The UI runs as UID 1000 (the image's default user). Its entrypoint writes runtime configuration files (webconfig.js, nginx conf, TLS certs) at startup, so readOnlyRootFilesystem must be false:

securityContext:
runAsUser: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]

The only required emptyDir mount is /tmp. The image's /var/run, /var/cache/nginx, /etc/nginx/templates, /etc/nginx/conf.d, and /var/run/swiftgw-webconfig directories are writable by UID 1000 in the image — do not mount emptyDir volumes over them, as this would erase the image's directory structure and cause startup failures.

Pod Security Context​

Both pods should set:

securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault

The backend pod also sets fsGroup: 0, so a mounted volume that arrives owned by root with mode 0755 — block volumes such as Azure Disk, EBS or Persistent Disk, and Filestore exports — is made writable by group 0:

securityContext:
runAsNonRoot: true
fsGroup: 0
fsGroupChangePolicy: OnRootMismatch # only re-own the volume when its root is wrong
seccompProfile:
type: RuntimeDefault

Local File Storage (LFS) Mode​

StorageLink can store files on a local filesystem instead of cloud storage. This is useful for air-gapped environments or when cloud storage is not required.

Configure the backend with:

FEATURES_FIRST_CONNECTION_CLOUD_PROVIDER: lfs
FEATURES_FIRST_CONNECTION_BASE_PREFIX: /mnt/data

On first launch, the backend creates a storage connection that points at the base prefix. There is no setting for the connection's display name.

Unlike cloud storage mode (where the backend is fully stateless), LFS mode stores files on the local filesystem and requires a PersistentVolumeClaim. When using LFS with multiple replicas, the volume must support ReadWriteMany so all backend pods can access the same files. The Helm chart and the Kubernetes manifests default to:

CloudStorage classSetup
AKSazurefile (Azure Files)Preinstalled
EKSefs-sc (Amazon EFS)Install the EFS CSI driver and create the class, with provisioningMode: efs-ap
GKEstandard-rwx (Filestore)Enable the GcpFilestoreCsiDriver add-on

Keep the backend pod's fsGroup: 0 (see Pod Security Context): Filestore exports arrive owned by root with mode 0755, and without fsGroup the backend cannot write to them.

Mount Path Rules​

StorageLink restricts which directories can be used as local storage mount paths. These rules prevent web administrators from accidentally exposing application configuration files (such as database credentials) or audit logs through the file browser.

Absolute paths must be entirely outside the application home directory (/opt/swiftgw). The following are all rejected:

PathWhy it's rejected
/opt/swiftgwThe application home itself
/opt/swiftgw/dataA subdirectory inside the application home
/opt/swiftgw/logWould expose audit and application logs
/optAn ancestor directory that contains the application home

These checks resolve symbolic links before comparing paths, so creating a symlink to bypass the restriction will not work.

Valid absolute paths are any directory outside the application home:

PathNotes
/mnt/dataRecommended for container deployments — mount a PVC here
/dataAny top-level directory outside /opt/swiftgw works
/var/storagelink/filesArbitrary path, as long as it's outside the app home

Relative paths (e.g., uploads, data) name a managed mount — a directory StorageLink creates for you inside a reserved location at /opt/swiftgw/mounts/.

Managed mounts are not supported in container deployments

You must use an absolute path. Set the base prefix to a directory outside /opt/swiftgw and mount a volume there.

The container image does not contain /opt/swiftgw/mounts, and the backend runs with a read-only root filesystem, so StorageLink cannot create it. A relative path is accepted when you save the storage connection but the first file transfer then fails with a Read-only file system error. Because the failure happens at transfer time rather than at save time, this is easy to miss until users report it.

Use /mnt/data — the path the Helm chart and the Kubernetes manifests already mount a PersistentVolumeClaim at:

FEATURES_FIRST_CONNECTION_BASE_PREFIX: /mnt/data

If you set the base prefix from the admin UI instead, enter /mnt/data in the Base prefix field rather than a bare name like uploads.

Managed mounts remain available on VM deployments, where the application home is writable. On a VM, the rules are:

  • uploads — valid, resolves to /opt/swiftgw/mounts/uploads
  • prod/uploads — valid, subdirectories are allowed
  • . — rejected, cannot be the mounts directory itself
  • ../escaped — rejected, cannot escape the mounts directory
tip

For container deployments, use an absolute path outside the app home, and mount a volume at it. /mnt/data is the convention used throughout this documentation and by the supplied Helm chart and Kubernetes manifests.

PostgreSQL Requirements​

StorageLink requires PostgreSQL 16 or later.

Required extension: The ltree extension is used by StorageLink's database schema. On self-managed PostgreSQL, this is typically available by default. On managed services, you may need to allowlist it explicitly:

  • Azure Database for PostgreSQL:
    az postgres flexible-server parameter set \
    --server-name <server> --resource-group <rg> \
    --name azure.extensions --value LTREE
  • AWS RDS / Aurora: The ltree extension is available by default.
  • Google Cloud SQL: The ltree extension is available by default.

Docker Compose Quickstart​

1. Generate Credentials​

SECURITY_CLIENT_ID=$(openssl rand -hex 16)
SECURITY_CLIENT_SECRET=$(openssl rand -hex 16)
SECURITY_JWT_SECRET=$(openssl rand -base64 32)

# Generate a self-signed TLS certificate
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=storagelink" 2>/dev/null

# Save to .env (Docker Compose reads this automatically)
{
echo "SECURITY_CLIENT_ID=${SECURITY_CLIENT_ID}"
echo "SECURITY_CLIENT_SECRET=${SECURITY_CLIENT_SECRET}"
echo "SECURITY_JWT_SECRET=${SECURITY_JWT_SECRET}"
printf 'WEBSITE_BUNDLE_CRT="%s"\n' "$(cat tls.crt)"
printf 'WEBSITE_KEY="%s"\n' "$(cat tls.key)"
} > .env

rm -f tls.crt tls.key
echo "Credentials saved to .env"

2. Create docker-compose.yml​

services:

db:
image: postgres:16-alpine
environment:
POSTGRES_DB: swift_gw
POSTGRES_USER: swiftgw
POSTGRES_PASSWORD: swiftgw
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U swiftgw -d swift_gw"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- storagelink

backend:
image: thorntech/storagelink-backend:1.3.1
depends_on:
db:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/swift_gw
SPRING_DATASOURCE_USERNAME: swiftgw
SPRING_DATASOURCE_PASSWORD: swiftgw
SPRING_PROFILES_ACTIVE: container,json-logs
SECURITY_CLIENT_ID: ${SECURITY_CLIENT_ID}
SECURITY_CLIENT_SECRET: ${SECURITY_CLIENT_SECRET}
SECURITY_JWT_SECRET: ${SECURITY_JWT_SECRET}
SERVER_PORT: "8080"
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: 128KB
FEATURES_SYSTEMD_NOTIFY: "false"
FEATURES_SFTP_SUBSYSTEM_AUTO_START: "false"
LICENSE_CONTENT: ${LICENSE_CONTENT:-} # optional: the whole license JSON
# Do not publish the backend port. Direct access allows X-Forwarded-For
# spoofing, which defeats per-IP login lockout and forges audit source IPs.
# The UI reaches the backend over the internal network via BACKEND_URL.
user: "100"
restart: unless-stopped
networks:
- storagelink

ui:
image: thorntech/storagelink-ui:1.3.1
depends_on:
- backend
environment:
BACKEND_URL: http://backend:8080/
SECURITY_CLIENT_ID: ${SECURITY_CLIENT_ID}
SECURITY_CLIENT_SECRET: ${SECURITY_CLIENT_SECRET}
CLOUD_PROVIDER: azure
WEBSITE_BUNDLE_CRT: ${WEBSITE_BUNDLE_CRT}
WEBSITE_KEY: ${WEBSITE_KEY}
ports:
- "443:8443"
user: "1000"
restart: unless-stopped
networks:
- storagelink

volumes:
postgres_data:

networks:
storagelink:
driver: bridge

3. Start the Services​

docker compose up -d

Access the admin dashboard at https://localhost. Create your admin account (see Initial Admin Account) and configure your cloud storage connection.

Kubernetes Deployment​

The following example deploys StorageLink as separate backend and UI pods with an external PostgreSQL database.

For a production-ready deployment with configurable values, see the cloud-specific Helm chart guides linked at the top of this page.

Prerequisites​

  • A Kubernetes cluster (AKS, EKS, GKE, or self-managed)
  • An external PostgreSQL database (or use the Helm chart's built-in PostgreSQL subchart)
  • kubectl configured for your cluster

Create the Namespace and Secrets​

kubectl create namespace storagelink

# Database credentials
kubectl create secret generic storagelink-db-secret \
--namespace storagelink \
--from-literal=POSTGRES_USER=swiftgw \
--from-literal=POSTGRES_PASSWORD='<your-db-password>' \
--from-literal=POSTGRES_DB=swift_gw

# Application secrets
CLIENT_ID=$(openssl rand -hex 16)
CLIENT_SECRET=$(openssl rand -hex 16)
JWT_SECRET=$(openssl rand -base64 32)

kubectl create secret generic storagelink-backend-secret \
--namespace storagelink \
--from-literal=SPRING_DATASOURCE_USERNAME=swiftgw \
--from-literal=SPRING_DATASOURCE_PASSWORD='<your-db-password>' \
--from-literal=SECURITY_CLIENT_ID="$CLIENT_ID" \
--from-literal=SECURITY_CLIENT_SECRET="$CLIENT_SECRET" \
--from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" \
--from-file=LICENSE_CONTENT=license.json # optional: omit to license from the admin UI

# Generate self-signed cert for testing
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-days 365 -nodes -subj "/CN=storagelink" 2>/dev/null

kubectl create secret generic storagelink-ui-secret \
--namespace storagelink \
--from-literal=SECURITY_CLIENT_ID="$CLIENT_ID" \
--from-literal=SECURITY_CLIENT_SECRET="$CLIENT_SECRET" \
--from-file=WEBSITE_BUNDLE_CRT=tls.crt \
--from-file=WEBSITE_KEY=tls.key

rm -f tls.crt tls.key

Backend Deployment​

apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-backend-config
namespace: storagelink
data:
SPRING_PROFILES_ACTIVE: container,json-logs
LOGGING_LEVEL_ROOT: INFO
SERVER_PORT: "8080"
# Microsoft Entra ID tokens exceed the image's 8KB header limit, so OIDC sign-in
# failed; StorageLink 1.4.0 makes 128KB the image default.
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: 128KB
DB_HOST: "<your-pg-host>"
SPRING_DATASOURCE_URL: "jdbc:postgresql://<your-pg-host>:5432/swift_gw?sslmode=require"
FEATURES_INSTANCE_CLOUD_PROVIDER: azure
FEATURES_SYSTEMD_NOTIFY: "false"
FEATURES_SFTP_SUBSYSTEM_AUTO_START: "false"

---

apiVersion: v1
kind: Service
metadata:
name: backend
namespace: storagelink
spec:
# WARNING: Do not change to LoadBalancer or NodePort. Direct access to the
# backend port allows an attacker to spoof X-Forwarded-For, defeating the
# per-IP login lockout and forging audit source IPs.
type: ClusterIP
selector:
app: backend
ports:
- name: http
port: 8080
targetPort: 8080

---

apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: storagelink
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
securityContext:
runAsNonRoot: true
fsGroup: 0
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: backend
topologyKey: kubernetes.io/hostname
containers:
- name: backend
image: thorntech/storagelink-backend:1.3.1
imagePullPolicy: Always
securityContext:
runAsGroup: 0 # no runAsUser: the image's own UID 1000 is used
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: storagelink-backend-secret
- configMapRef:
name: storagelink-backend-config
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "3Gi"
cpu: "1500m"
volumeMounts:
- name: tmp
mountPath: /tmp
startupProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 180
periodSeconds: 20
volumes:
- name: tmp
emptyDir: {}

UI Deployment​

apiVersion: v1
kind: ConfigMap
metadata:
name: storagelink-ui-config
namespace: storagelink
data:
BACKEND_URL: "http://backend:8080/"
CLOUD_PROVIDER: azure

---

apiVersion: v1
kind: Service
metadata:
name: ui
namespace: storagelink
spec:
type: LoadBalancer
# Local preserves client IP; Cluster allows cross-node balancing but SNATs the source.
externalTrafficPolicy: Local
loadBalancerSourceRanges:
- "REPLACE_WITH_YOUR_CIDR" # e.g. "203.0.113.0/24" — restrict to your network
selector:
app: ui
ports:
- name: https
port: 443
targetPort: 8443

---

apiVersion: apps/v1
kind: Deployment
metadata:
name: ui
namespace: storagelink
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ui
template:
metadata:
labels:
app: ui
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: ui
topologyKey: kubernetes.io/hostname
containers:
- name: ui
image: thorntech/storagelink-ui:1.3.1
imagePullPolicy: Always
securityContext:
runAsUser: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
ports:
- containerPort: 8443
envFrom:
- secretRef:
name: storagelink-ui-secret
- configMapRef:
name: storagelink-ui-config
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
volumeMounts:
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet:
path: /index.html
port: 8443
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /index.html
port: 8443
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 20
volumes:
- name: tmp
emptyDir: {}

Verify the Deployment​

# Watch pods come up
kubectl get pods -n storagelink -w

# Get the UI external IP (may take a few minutes)
kubectl get svc ui -n storagelink

# Check backend logs
kubectl logs -n storagelink -l app=backend -f

The backend may take 1–2 minutes to start on first launch due to database migrations.

Open https://<EXTERNAL-IP> in your browser to access the StorageLink admin dashboard. You will see a certificate warning if using a self-signed certificate — this is expected. If you provided admin.username and admin.password in the Helm values, log in with those credentials. Otherwise, create your admin account on first access (see Initial Admin Account).

Troubleshooting​

Backend CrashLoopBackOff​

Check the backend logs:

kubectl logs -n storagelink -l app=backend --previous --tail=50

Common causes:

  • ltree extension not available — See PostgreSQL Requirements.
  • Wrong database credentials — Verify secrets match the PostgreSQL server.
  • Database not reachable — Check network connectivity and firewall rules.

Backend Stuck on "Waiting for changelog lock"​

This happens when a previous backend instance crashed during database migrations, leaving a stale Liquibase lock:

UPDATE databasechangeloglock
SET locked = false, lockgranted = null, lockedby = null
WHERE id = 1;

The Helm chart and the Kubernetes manifests include an init container that, on every pod start, releases a lock held for more than 5 minutes. To clear a newer lock by hand, scale the backend to 0 first, so you do not break the lock of a replica that is still migrating.

UI Shows "Bad Gateway" or Cannot Reach Backend​

Verify the backend pod is running and the BACKEND_URL is correct:

kubectl get pods -n storagelink -l app=backend
kubectl get endpoints backend -n storagelink

UI Pods Restart With No Errors in the Log​

If the UI pods keep restarting or never become Ready, and their log shows no error, the probes are failing. This happens with EXTERNAL_TLS_TERMINATION=true when the probes still check HTTPS on port 8443, or check /, which redirects to https://<host>/index.html on port 443. Probe /index.html on port 8080 over HTTP. Helm chart 0.4.0 does this automatically. On chart 0.3.0, override the probes in your values:

ui:
readinessProbe:
httpGet: {path: /index.html, port: 8080, scheme: HTTP}
livenessProbe:
httpGet: {path: /index.html, port: 8080, scheme: HTTP}

Blank Page Behind an Ingress or Load Balancer​

StorageLink must be served from the root of a hostname. Behind a path prefix such as example.com/storagelink, the Admin UI page loads but stays blank, because its scripts and API calls are requested from /. Give StorageLink its own hostname and route all of / to the UI — see Behind an Ingress Controller or L7 Load Balancer.

TLS Certificate Not Working​

  • Check that both WEBSITE_BUNDLE_CRT and WEBSITE_KEY are set. Providing only one causes the container to exit.
  • Ensure the values are PEM-encoded and not base64-wrapped (the container expects raw PEM).
  • For certificate chains, the server certificate must come first.

OIDC Sign-In Fails or Works Only in a Private Window​

Sign-in through an identity provider returns to StorageLink with a token in the request headers. The backend image accepts headers up to 8KB, and Microsoft Entra ID tokens are often larger, so the sign-in fails, or works only in a private window, where the browser sends fewer cookies. Set SERVER_MAX_HTTP_REQUEST_HEADER_SIZE=128KB on the backend. Helm chart 0.4.2 and later set it by default; on an older chart release, set it on the running Deployment until you upgrade:

kubectl -n storagelink set env deployment/storagelink-backend SERVER_MAX_HTTP_REQUEST_HEADER_SIZE=128KB

StorageLink 1.4.0 makes 128KB the image default.

Verifying Image Integrity​

StorageLink container images include supply chain attestations — an SBOM (Software Bill of Materials) and build provenance — embedded at build time. These allow you to verify image contents and build origin before deploying.

Inspect Attestations​

Use docker buildx imagetools to view the attestations attached to an image:

docker buildx imagetools inspect thorntech/storagelink-backend:1.3.1

The output includes manifest entries for:

  • SBOM (application/spdx+json) — lists all packages and dependencies in the image
  • Provenance (application/vnd.in-toto+json) — records the build environment, source commit, and build parameters

Extract SBOM​

To extract the full SBOM as a JSON file:

# View SBOM attestation
docker buildx imagetools inspect thorntech/storagelink-backend:1.3.1 --format '{{ json .SBOM }}' > sbom.json

# List all packages
cat sbom.json | jq '.SPDX.packages[].name'

Extract Provenance​

To view the build provenance (source commit, builder identity, build parameters):

docker buildx imagetools inspect thorntech/storagelink-backend:1.3.1 --format '{{ json .Provenance }}' > provenance.json

Verification in CI/CD​

You can gate deployments on attestation verification. Example using docker buildx imagetools inspect in a pipeline:

# Fail if no attestations are present
ATTESTATIONS=$(docker buildx imagetools inspect thorntech/storagelink-backend:1.3.1 --format '{{ json .Manifest }}' \
| jq '[.manifests[] | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest")] | length')

if [ "$ATTESTATIONS" -lt 1 ]; then
echo "ERROR: No attestations found on image"
exit 1
fi
echo "Image has $ATTESTATIONS attestation(s)"

Troubleshooting​

"no attestation manifests found":

  • Pre-release or development builds may not include attestations
  • Ensure you are inspecting the multi-arch manifest (not a platform-specific digest)

Cannot pull attestation layers:

  • Attestations are stored as OCI referrers alongside the image — ensure your registry supports OCI referrers (Docker Hub, ECR, and most modern registries do)