Kubernetes
The Docker walkthrough under the chart: local storage on
a volume, the accounts in a Secret, the roles in config.toml.
kubectl create secret generic sftp-host-keys --from-file=host_ed25519
kubectl create secret generic sftp-users --from-file=users.toml
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: sftp-data }
spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 10Gi } } }
EOF
helm install sftp deploy/helm/craft-file-gate -f sftp-values.yaml \
--set-file config.inline=config.toml
# sftp-values.yaml
hostKeys:
existingSecret: sftp-host-keys # /keys/host_ed25519
extraVolumes:
- name: data
persistentVolumeClaim: { claimName: sftp-data }
- name: users
secret: { secretName: sftp-users, defaultMode: 0440 }
extraVolumeMounts:
- { name: data, mountPath: /data } # the backend's root = "/data"
- { name: users, mountPath: /secrets/users, readOnly: true }
config.toml is the Docker one followed by the content of its roles.toml (the
chart mounts only config.toml), with host_keys = ["/keys/host_ed25519"]
under [sftp] and users_file = "/secrets/users/users.toml" under [auth].
Listeners stay on 0.0.0.0 (the probes reach the pod by its IP) and
[[admin.roles]] opens the console, since the chart sets the static token aside.
The volume belongs to the pod’s group (fsGroup 65532): create_home creates
alice/ there. Beyond one pod, the volume is ReadWriteMany.
The chart
| Value | Default | Effect |
|---|---|---|
replicaCount | 1 | number of pods; above 1, see Several pods |
config.inline | "" | the content of config.toml, rendered into the ConfigMap <release>-config; a change rolls the pods |
config.existingConfigMap | "" | or a ConfigMap of your own, key config.toml; exactly one of the two sources |
config.allowStaticToken | false | allow_static_token, written under [admin] in config.inline unless the configuration sets it; with existingConfigMap, write it yourself. See Authenticating |
extraVolumes, extraVolumeMounts, extraEnv, extraEnvFrom | [] | added as is to the pod and its container, after the chart’s: a volume for the root of a local backend, a Secret |
image.repository, image.tag, image.pullPolicy | craftogether/craft-file-gate, the chart’s version, IfNotPresent | the image |
hostKeys.existingSecret | "" | the Secret of the host keys, mounted in hostKeys.mountPath (/keys), files in 0440 with the pod’s fsGroup; see Secrets |
service.sftp.port, service.sftp.type | 2222, ClusterIP | the Service <release>, SFTP only |
service.admin.port, service.admin.type | 8080, ClusterIP | the Service <release>-api: file API, file explorer; absent with a config.inline without [admin] |
service.control.port | 8081 | control_listen: /admin, /metrics, probes; "" for a single door; ignored with a config.inline without [admin] |
service.control.type | ClusterIP | the type of the Service <release>-control: control port and exposed probe port |
service.probes.port | "" | probes_listen: /livez, /readyz, /health and /metrics on their own port, over HTTP; the probes go there |
service.probes.expose | false | this port on the Service <release>-control too: without it, probes and scraping go through the pod IP |
networkPolicy.enabled | true | an ingress NetworkPolicy on the pod |
networkPolicy.publicFrom | []: any source | sources allowed on SFTP and the admin port |
networkPolicy.controlFrom | - namespaceSelector: {}: any pod of the cluster | sources allowed on the control port and the probe port; at least one |
ban.backend | file | configmap to share bans between pods |
ban.configmapName | "": <release>-bans | the bans ConfigMap, created by the chart and given to the server through CRAFT_FILE_GATE_{SFTP,API,ADMIN}_BAN_CONFIGMAP_NAME |
stateDir.enabled, stateDir.path | true, /var/lib/craft-file-gate | an emptyDir for the bans file |
logFiles.enabled, logFiles.dir | false, /var/log/craft-file-gate | log files, in an emptyDir or logFiles.existingClaim |
passwordHashing.workers | "": requests.cpu | hashing threads; see Checking passwords |
passwordHashing.queue | "": 1024 | hashing queue |
resources | requests 100m, 64Mi; limits 500m, 256Mi | see Memory |
tls.enabled | false | probes over HTTPS, except on service.probes.port; [admin.tls] is set in config.toml |
terminationGracePeriodSeconds | "": the shutdown_grace_period_secs of config.inline plus 60 s, otherwise 90 s | the pod’s shutdown timeout, above the worst case of a shutdown; set it with an existingConfigMap that lengthens the timeout |
clusterSecret.*, adminRevocations.*, adminGrants.*, access.* | the session key and the revocations shared between pods: Secrets | |
cluster.enabled, cluster.port | "": as soon as replicaCount exceeds 1; 8083 | the channel between pods: Cluster |
cluster.minPeers, cluster.unreadyWhen | "": floor(replicaCount / 2); "": alert only | the detectors; unreadyWhen: a list, or auto (storage_alone, isolated_and_storage_down) |
- One Service per exposure: publishing SFTP through a
LoadBalancer(service.sftp.type) publishes neither the console, nor/metrics, nor the probes. - The kubelet probes come from the pod’s node, which most CNIs
let through despite the NetworkPolicy; otherwise, add the node CIDR
as an
ipBlocktonetworkPolicy.controlFrom. To restrict Prometheus and the ingress, list their namespaces innetworkPolicy.controlFrom.
Probes
/livez and /readyz (Probes and health) are
probed on the probe port when one is given, otherwise on the control
port, otherwise on the admin port.
A port for the probes
service.probes.port: 9090 sets [server] probes_listen: the probes and
/metrics get a port of their own, in plain HTTP, even under [admin.tls]
(One door or two). The kubelet and
Prometheus (per pod: PodMonitor or annotations) no longer need the
certificate or the console port; the port is on the Service
<release>-control only with service.probes.expose: true. An SFTP pod without
[admin] thus has its probes, with /readyz ready as soon as the SFTP door listens.
Pod hardening
The chart applies the restricted profile of the Pod Security Standards. Each
field can be overridden in podSecurityContext and securityContext; null
removes a field or a block.
| Setting | Value |
|---|---|
| user | runAsNonRoot: true, runAsUser, runAsGroup, fsGroup: 65532 |
| privileges | allowPrivilegeEscalation: false, privileged: false, capabilities.drop: [ALL] |
| seccomp | RuntimeDefault |
| root | readOnlyRootFilesystem: true: the server writes only to a volume |
| service account token | mounted with ban.backend: configmap, the shared Secret (clusterSecret) or the access ConfigMap (adminRevocations, adminGrants) |
| configuration, host keys | mounted readOnly: true |
The root of a local backend is a volume from extraVolumes. Host keys and the TLS pair come from a Secret
(cert-manager for TLS), never from an emptyDir: auto_generate of [admin.tls] does not apply under this chart.
Several pods
| What is shared | How | See |
|---|---|---|
| the pods find each other | as soon as replicaCount exceeds 1: headless Service <release>-cluster, port cluster.port reserved to the pods of the release (app.kubernetes.io/instance), certificate in the shared Secret | Cluster |
| console session key | the shared Secret (clusterSecret), entry admin-session.key | The pods’ shared Secret |
| console revocations, temporary access | the ConfigMap <release>-access (access.configmapName) | Sharing revocations |
| bans | ConfigMap (ban.backend: configmap), followed by a watch: a bit over 100 ms; or a file on a ReadWriteMany volume (persist_file), re-read every 5 s, eventual convergence | Bans |
| sessions, metrics, rate limits | nothing: each pod has its own; GET /admin/sessions lists those of the pod that answers, each with its pod_name (HOSTNAME); scope=cluster (the console) those of all |
Each Role covers a single name, without create: get, update on the Secret; get, update, patch
on each ConfigMap, watch on their collection for bans (helm template ... --show-only templates/rbac.yaml).
Sharing through a ConfigMap
Feature k8s (in the published images). The pod reads the ConfigMap before
serving; each publication is conditioned on the resourceVersion it read, and
a data.bans erased by kubectl edit is published again on the next one. Without
watch, propagation falls back to a re-read every 30 s; a
deployment that does not create the ConfigMap adds create on the collection.
[sftp.ban] # same under [api.ban] and [admin.ban]; the chart provides the name
backend = "configmap"
inotify on a shared node
fs.inotify.max_user_instances (128 by default) is counted per UID, across
the whole node. The server takes only one. When other pods have used them up and
you cannot tune the node:
[reload]
watch = "poll" # no inotify instance; a Secret update is seen within 5 s
See Hot reload.