Thorn Tech Marketing Ad
Skip to main content
Version: Next

Container Deployment Reference

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

Container images are delivered per your engagement — contact your Thorn Technologies representative for registry credentials and 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 secretYes
LICENSEStorageLink license key (use trial for evaluation)Yes
SERVER_PORTAPI server port (default: 8080)No
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_NAMEDisplay name for the pre-configured connectionNo
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, ALB Ingress, or a cloud L7 load balancer) before reaching the UI pods, the ingress layer must:

  1. 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.
  2. 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.
  3. Set X-Forwarded-For — Most ingress controllers do this by default.

With EXTERNAL_TLS_TERMINATION=true, set the UI service to ClusterIP and configure the Ingress resource to handle TLS:

# UI Service (ClusterIP, no externalTrafficPolicy needed)
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
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.

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-Protohttps 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 runs as UID 100 (swiftgw) with a read-only root filesystem. It requires emptyDir mounts for writable paths:

securityContext:
runAsUser: 100
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. 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

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_NAME: "Local Storage"
FEATURES_FIRST_CONNECTION_BASE_PREFIX: /mnt/data

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 (e.g., Azure Files, EFS, or NFS) so all backend pods can access the same files.

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) are treated as managed mount names. They resolve inside a reserved directory at /opt/swiftgw/mounts/. For example, uploads becomes /opt/swiftgw/mounts/uploads. This is a safe exception to the "outside app home" rule — the mounts/ subdirectory is specifically designed for this purpose and does not contain application secrets. Managed mount rules:

  • 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, absolute paths outside the app home (like /mnt/data) are the simplest approach — just mount a PVC at that path. Managed mounts are more useful for VM deployments where you want StorageLink to manage the directory structure.

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: <backend-image> # replace with your image reference
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"
FEATURES_SYSTEMD_NOTIFY: "false"
FEATURES_SFTP_SUBSYSTEM_AUTO_START: "false"
LICENSE: trial
# 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: <ui-image> # replace with your image reference
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-literal=LICENSE='<your-license-key>'

# 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"
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
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: backend
topologyKey: kubernetes.io/hostname
containers:
- name: backend
image: <backend-image> # replace with your image reference
imagePullPolicy: Always
securityContext:
runAsUser: 100
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: <ui-image> # replace with your image reference
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: /
port: 8443
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
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 includes an init container that clears this automatically on every pod start.

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

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.

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 <backend-image>:<tag>

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 <backend-image>:<tag> --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 <backend-image>:<tag> --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 <backend-image>:<tag> --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)