Deploy SFTP Gateway with Helm Chart on AKS
TLDR - Quick Summary
What: Deploy SFTP Gateway on AKS using a Helm chart with either bundled PostgreSQL or Azure Database for PostgreSQL
Steps: Download chart, configure prerequisites, run
helm installQuick start:
helm install sftpgw ./sftp-gateway-azure \ --namespace sftpgw --create-namespace \ --set security.clientId=$(openssl rand -hex 16) \ --set security.clientSecret=$(openssl rand -hex 32) \ --set security.jwtSecret=$(uuidgen) \ --set postgresql.auth.password=$(openssl rand -hex 16) \ --set azure.container=YOUR_CONTAINER_NAME
Overview
The SFTP Gateway Helm chart simplifies deploying SFTP Gateway on Azure Kubernetes Service (AKS). It handles creating all the Kubernetes resources (Deployments, Services, ConfigMaps, Secrets, PVCs, ServiceAccount) and supports two database modes:
- Bundled PostgreSQL (default) — runs a PostgreSQL container inside the cluster. Good for testing and simple deployments.
- External database — connects to a managed Azure Database for PostgreSQL Flexible Server. Recommended for production.
Docker Hub Images
The SFTP Gateway container images are available on Docker Hub:
- Backend:
thorntech/sftpgateway-backend:3.8.1 - Admin UI:
thorntech/sftpgateway-admin-ui:3.8.1
Both images support amd64 and arm64 architectures.
Prerequisites
- An AKS cluster with at least 4 GB of allocatable memory
- Helm 3 installed
kubectlconfigured to access your cluster- An Azure Blob Storage account and container for SFTP file storage
:::tip If you already have an AKS cluster from the Deploy on AKS guide, you can skip ahead to Install the Helm Chart. :::
Step 1: Create an AKS cluster
# Create a resource group
az group create --name sftpgw-rg --location eastus
# Create the AKS cluster (this takes ~5-10 minutes)
az aks create \
--resource-group sftpgw-rg \
--name sftpgw-aks \
--node-count 2 \
--node-vm-size Standard_D2s_v3 \
--generate-ssh-keys
# Get credentials for kubectl
az aks get-credentials --resource-group sftpgw-rg --name sftpgw-aks --overwrite-existing
Verify the cluster is ready:
kubectl get nodes
Step 2: Create an Azure Blob Storage account
# Create a storage account (name must be globally unique — replace YOUR_STORAGE_ACCOUNT)
az storage account create \
--name YOUR_STORAGE_ACCOUNT \
--resource-group sftpgw-rg \
--location eastus \
--sku Standard_LRS
# Create a blob container
az storage container create \
--name sftpgw-files \
--account-name YOUR_STORAGE_ACCOUNT
:::tip
Storage account names must be globally unique across all of Azure, 3-24 characters, lowercase letters and numbers only. If your chosen name is taken, try appending random characters (e.g., sftpgwstore7a3b).
:::
Step 3: Get the storage connection string
az storage account show-connection-string \
--name YOUR_STORAGE_ACCOUNT \
--resource-group sftpgw-rg \
--output tsv
Save this value — you'll use it when configuring the cloud connection in the Admin UI after deployment.
:::note
The Helm chart does not require the connection string at install time. You configure it later through the Admin UI. The azure.connectionString value is optional during install.
:::
Step 4: Download the Helm chart
curl -LO https://thorntech-public-documents.s3.amazonaws.com/sftpgateway/helm-charts/azure/sftp-gateway-azure-0.1.0.tgz
tar xzf sftp-gateway-azure-0.1.0.tgz
cd sftp-gateway-azure
The chart includes the bundled PostgreSQL dependency in the charts/ directory, so no additional downloads are needed.
Option A: Bundled PostgreSQL (quickstart)
This is the simplest option — a PostgreSQL container runs alongside the backend inside your cluster.
Install
helm install sftpgw ./sftp-gateway-azure \
--namespace sftpgw --create-namespace \
--set security.clientId=$(openssl rand -hex 16) \
--set security.clientSecret=$(openssl rand -hex 32) \
--set security.jwtSecret=$(uuidgen) \
--set postgresql.auth.password=$(openssl rand -hex 16) \
--set azure.container=sftpgw-files
After a few minutes, all pods should be running:
kubectl get pods -n sftpgw
Expected output:
NAME READY STATUS RESTARTS AGE
sftpgw-postgresql-0 1/1 Running 0 2m
sftpgw-sftp-gateway-azure-backend-xxxxx 1/1 Running 0 2m
sftpgw-sftp-gateway-azure-ui-xxxxx 1/1 Running 0 2m
sftpgw-sftp-gateway-azure-ui-yyyyy 1/1 Running 0 2m
Option B: Azure Database for PostgreSQL (production)
For production deployments, use a managed Azure Database for PostgreSQL Flexible Server.
Create the database
# Create a PostgreSQL Flexible Server
az postgres flexible-server create \
--resource-group sftpgw-rg \
--name sftpgw-db \
--location eastus \
--admin-user sftpgw \
--admin-password YOUR_DATABASE_PASSWORD \
--sku-name Standard_B1ms \
--tier Burstable \
--storage-size 32 \
--version 16 \
--database-name sftpgw \
--public-access None
:::warning
The --public-access None flag blocks all external connections by default. You must add a firewall rule for your AKS cluster's outbound IP (shown below). Avoid using --public-access 0.0.0.0 as it opens the database to the entire internet.
:::
# Allow access from AKS (get the AKS outbound IP)
AKS_OUTBOUND_IP=$(az aks show --resource-group sftpgw-rg --name sftpgw-aks \
--query "networkProfile.loadBalancerProfile.effectiveOutboundIPs[0].id" -o tsv | \
xargs az network public-ip show --ids --query ipAddress -o tsv)
az postgres flexible-server firewall-rule create \
--resource-group sftpgw-rg \
--name sftpgw-db \
--rule-name allow-aks \
--start-ip-address $AKS_OUTBOUND_IP \
--end-ip-address $AKS_OUTBOUND_IP
# Get the server hostname
az postgres flexible-server show --resource-group sftpgw-rg --name sftpgw-db \
--query fullyQualifiedDomainName -o tsv
# Output: sftpgw-db.postgres.database.azure.com
Install with external database
helm install sftpgw ./sftp-gateway-azure \
--namespace sftpgw --create-namespace \
--set security.clientId=$(openssl rand -hex 16) \
--set security.clientSecret=$(openssl rand -hex 32) \
--set security.jwtSecret=$(uuidgen) \
--set azure.container=sftpgw-files \
--set postgresql.enabled=false \
--set externalDatabase.host=sftpgw-db.postgres.database.azure.com \
--set externalDatabase.password=YOUR_DATABASE_PASSWORD
:::note Azure PostgreSQL SSL Azure Database for PostgreSQL Flexible Server enforces SSL connections by default. If the backend fails to connect with an SSL-related error, you can either:
Append SSL parameters to the JDBC URL (recommended) — override the database URL directly:
--set externalDatabase.jdbcParams="?sslmode=require"Disable SSL enforcement on the server (not recommended for production):
az postgres flexible-server parameter set \ --resource-group sftpgw-rg --server-name sftpgw-db \ --name require_secure_transport --value off
The bundled PostgreSQL (Option A) does not use SSL, so this only applies to external databases. :::
Expected output:
NAME READY STATUS RESTARTS AGE
sftpgw-sftp-gateway-azure-backend-xxxxx 1/1 Running 0 3m
sftpgw-sftp-gateway-azure-ui-xxxxx 1/1 Running 0 3m
sftpgw-sftp-gateway-azure-ui-yyyyy 1/1 Running 0 3m
Access the deployment
Get the Admin UI URL
# Wait for the LoadBalancer to be provisioned
kubectl get svc -n sftpgw -w
# Get the UI IP address
export UI_IP=$(kubectl get svc sftpgw-sftp-gateway-azure-ui -n sftpgw \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo "Admin UI: https://$UI_IP"
Open the URL in your browser. You'll see a certificate warning because the chart generates a self-signed TLS certificate by default — this is expected. Accept the warning to continue.
:::note Azure LoadBalancers use IP addresses (not DNS hostnames) for their endpoints. The IP is assigned within 1-2 minutes of service creation. :::
Get the SFTP endpoint
export SFTP_HOST=$(kubectl get svc sftpgw-sftp-gateway-azure-sftp -n sftpgw \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo "SFTP Host: $SFTP_HOST"
echo "SFTP Port: 22"
Initial setup
- Open the Admin UI in your browser
- Create your initial Web Admin account
- Configure Azure Blob Storage connection:
- Navigate to Cloud Connections in the Admin UI
- Enter your storage account connection string
- Enter your blob container name
- Create SFTP users
- Connect with your SFTP client (FileZilla, WinSCP, etc.)
:::warning License required for SFTP SFTP connections require a valid license key. Without one, the backend runs normally and the Admin UI is fully accessible, but SFTP clients will be immediately disconnected with "The SFTP Server's license has expired." To add your license during install:
--set license.key=YOUR_LICENSE_KEY
Or add it later via helm upgrade --set license.key=YOUR_LICENSE_KEY --reuse-values.
:::
Configuration reference
Key values
| Parameter | Description | Default |
|---|---|---|
azure.connectionString | Azure Blob Storage connection string (optional — can be set in Admin UI) | "" |
azure.container | Blob container name | "" |
backend.replicaCount | Number of backend replicas | 1 |
backend.resources.requests.cpu | Backend CPU request | 500m |
backend.resources.requests.memory | Backend memory request | 2Gi |
backend.resources.limits.cpu | Backend CPU limit | 1500m |
backend.resources.limits.memory | Backend memory limit | 4Gi |
backend.service.externalTrafficPolicy | Local preserves client IP, Cluster for cross-node balancing | Local |
backend.persistence.storageClass | Storage class for home directory PVC | managed-csi |
ui.replicaCount | Number of UI replicas | 2 |
ui.tls.certificate | Custom TLS certificate (PEM) | "" (self-signed) |
ui.tls.privateKey | Custom TLS private key (PEM) | "" |
ui.service.loadBalancerSourceRanges | Restrict Admin UI to specific IPs | [] |
config.javaOpts | JVM options for backend | "" |
postgresql.enabled | Use bundled PostgreSQL | true |
postgresql.auth.password | PostgreSQL password (required) | "" |
externalDatabase.host | Azure Database for PostgreSQL endpoint | "" |
externalDatabase.port | Database port | 5432 |
externalDatabase.password | Database password | "" |
license.key | SFTP Gateway license key | "" |
Custom TLS certificate
To use your own TLS certificate instead of the auto-generated self-signed one:
helm install sftpgw ./sftp-gateway-azure \
--namespace sftpgw --create-namespace \
--set-file ui.tls.certificate=path/to/tls.crt \
--set-file ui.tls.privateKey=path/to/tls.key \
# ... other values
Restrict Admin UI access
Lock down the Admin UI to specific IP addresses:
# values-production.yaml
ui:
service:
loadBalancerSourceRanges:
- "203.0.113.50/32" # Office IP
- "198.51.100.0/24" # VPN range
helm install sftpgw ./sftp-gateway-azure -f values-production.yaml --namespace sftpgw --create-namespace
Managed Identity for Blob Storage
Instead of using a connection string, you can configure Managed Identity for keyless access to Azure Blob Storage:
Enable Managed Identity on your AKS cluster:
az aks update --resource-group sftpgw-rg --name sftpgw-aks --enable-managed-identityAssign the Storage Blob Data Contributor role to the AKS managed identity:
AKS_IDENTITY=$(az aks show --resource-group sftpgw-rg --name sftpgw-aks \ --query "identityProfile.kubeletidentity.objectId" -o tsv) STORAGE_ID=$(az storage account show --name sftpgwstorage --resource-group sftpgw-rg --query id -o tsv) az role assignment create \ --assignee $AKS_IDENTITY \ --role "Storage Blob Data Contributor" \ --scope $STORAGE_IDConfigure the storage connection in the Admin UI using Managed Identity instead of a connection string.
Upgrading
helm upgrade sftpgw ./sftp-gateway-azure \
--namespace sftpgw \
--reuse-values
:::warning
If you used --set flags during install, you must pass the same values during upgrade (or use --reuse-values). Helm does not persist --set values between releases.
:::
Uninstalling
helm uninstall sftpgw --namespace sftpgw
# PVCs are not deleted automatically — remove if no longer needed:
kubectl delete pvc --all -n sftpgw
Troubleshooting
Backend pod stuck in CrashLoopBackOff
Check the logs:
kubectl logs -n sftpgw -l app.kubernetes.io/component=backend --tail=50
Common causes:
- Database connection failed: Verify the Azure Database for PostgreSQL is running and firewall rules allow traffic from the AKS cluster
- SSL connection error (external database): Azure PostgreSQL enforces SSL by default. See the SSL note above
- Insufficient memory: The backend requires at least 2Gi of memory. Check node allocatable resources
PVCs stuck in Pending
AKS uses the managed-csi storage class by default, which provisions Azure Managed Disks:
# Verify the storage class exists
kubectl get storageclass
# Check PVC events
kubectl describe pvc -n sftpgw
If managed-csi is not available, you may need to use default or create a custom StorageClass.
UI pod in CrashLoopBackOff
The UI container requires TLS certificates. The Helm chart auto-generates a self-signed certificate, but if you see nginx certificate errors, verify the secret exists:
kubectl get secret -n sftpgw -l app.kubernetes.io/instance=sftpgw
Backend pod not becoming Ready
The backend takes approximately 60-90 seconds to start. Check readiness probe status:
kubectl describe pod -n sftpgw -l app.kubernetes.io/component=backend
PVC Multi-Attach errors during upgrades
If the backend pod is stuck with a Multi-Attach error, the old pod is still holding the PVC. Azure Managed Disks are ReadWriteOnce and can only be attached to one node:
# Delete the old pod
kubectl delete pod <old-pod-name> -n sftpgw
# Or use a rolling update strategy with maxUnavailable=1
External IP stuck on <pending>
If the LoadBalancer external IPs remain in <pending> state:
kubectl describe svc -n sftpgw
Check the Events section for error messages. Common causes include insufficient permissions on the AKS managed identity or Azure subscription quota limits.