SFTP¶
The SFTP backend stores the kopia repository on a server reached over SSH/SFTP
— a NAS, a VPS, anything you can sftp into. Kopiur uses key-based auth: an
SSH private key and a pinned known_hosts entry, both delivered as files.
Reach for SFTP when the target is a remote host with SSH but no object-store API. For the same NAS mounted as a volume, see filesystem.
Provider prerequisites¶
- An SFTP account on the server and a path for the repository (e.g.
/volume1/kopia). The account must be able to read/write/create there. - An SSH private key for that account (key-based auth — password auth is not
wired). Add the matching public key to the server's
authorized_keys. -
The server's host key, so you pin
known_hostsinstead of trust-on-first-use:
Minting a dedicated keypair for the mover
Don't reuse your personal SSH key — generate one that exists only for this repository, so it can be rotated or revoked alone:
# 1. A fresh ed25519 keypair, no passphrase (the mover can't answer a prompt):
$ ssh-keygen -t ed25519 -N "" -C kopiur-mover -f kopia_sftp
# 2. Authorize the PUBLIC half on the server, for the SFTP account:
$ ssh-copy-id -i kopia_sftp.pub kopia@nas.lan
# (or append kopia_sftp.pub to ~kopia/.ssh/authorized_keys by hand)
# 3. Sanity-check before touching Kubernetes:
$ sftp -i kopia_sftp kopia@nas.lan
# 4. The PRIVATE half (the file `kopia_sftp`, the whole BEGIN…END block)
# goes under KOPIA_SFTP_KEY_DATA in the Secret.
The Secret shape¶
SFTP is one of the three file-delivered backends, and the most asked-about, so here is exactly what the Secret looks like.
kopia's SFTP backend has no environment-variable credential form, and a Secret key
like ssh-privatekey is not a valid environment-variable name — envFrom
silently drops dashed keys. So Kopiur standardizes on two valid-identifier env
keys; the mover reads them, writes each to a private (0600) file, and passes
--keyfile / --known-hosts to kopia.
| Secret key | Required | What it is | Becomes |
|---|---|---|---|
KOPIA_SFTP_KEY_DATA |
yes | The SSH private key, PEM, verbatim (the whole BEGIN…END block). |
a 0600 keyfile → kopia --keyfile |
KOPIA_SFTP_KNOWN_HOSTS |
yes | One known_hosts line for the server (from ssh-keyscan). |
a file → kopia --known-hosts |
KOPIA_PASSWORD |
yes | The repository encryption password. | env var kopia reads |
stringData:
KOPIA_SFTP_KEY_DATA: |
-----BEGIN OPENSSH PRIVATE KEY-----
REPLACE_ME
-----END OPENSSH PRIVATE KEY-----
KOPIA_SFTP_KNOWN_HOSTS: "nas.lan ssh-ed25519 AAAAC3Nz...REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
The complete, apply-ready Secret + Repository is below.
Why these key names (and not ssh-privatekey)
The key names must be valid environment-variable identifiers because the mover
loads them with envFrom. ssh-privatekey contains a dash and would be dropped,
so Kopiur uses KOPIA_SFTP_KEY_DATA and KOPIA_SFTP_KNOWN_HOSTS. You provide the
values; the mover writes the files and never puts the key on kopia's argv.
The Repository¶
---
apiVersion: v1
kind: Secret
metadata:
name: sftp-repo-creds
namespace: backups
type: Opaque
stringData:
# SSH key-based auth. kopia's SFTP backend has no env-var credential form, so
# the operator's mover reads these two keys from the env (envFrom) and writes
# them to files, then passes `--keyfile`/`--known-hosts` to kopia. The key
# NAMES must be valid env identifiers (this is why it's KOPIA_SFTP_KEY_DATA,
# not `ssh-privatekey` — a dashed key is dropped by Kubernetes envFrom).
KOPIA_SFTP_KEY_DATA: |
-----BEGIN OPENSSH PRIVATE KEY-----
REPLACE_ME
-----END OPENSSH PRIVATE KEY-----
# Pin the server host key (avoid trust-on-first-use). Get it with:
# ssh-keyscan -p 22 nas.lan
KOPIA_SFTP_KNOWN_HOSTS: "nas.lan ssh-ed25519 AAAAC3Nz...REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: sftp-primary
namespace: backups
spec:
backend:
sftp:
host: nas.lan # SFTP server hostname or IP
port: 22 # optional; defaults to 22
path: /volume1/kopia # remote path holding the repository
username: backup # SSH user to connect as
auth:
secretRef:
name: sftp-repo-creds
encryption:
passwordSecretRef:
name: sftp-repo-creds
key: KOPIA_PASSWORD
create:
enabled: true
Fields reference (backend.sftp)¶
| Field | Required | Default | Example | What it controls |
|---|---|---|---|---|
host |
yes | — | nas.lan |
SFTP server hostname or IP. Must match the name in the known_hosts line (it's how the entry is looked up). |
path |
yes | — | /volume1/kopia |
Remote absolute path on the server that holds the repository. The SSH user must be able to write it. |
port |
no | 22 |
2222 |
TCP port. A non-22 port changes the known_hosts format — see the warning below. |
username |
no | — | kopia |
SSH user to connect as — the account whose authorized_keys holds the public key. |
auth.secretRef |
no | — | { name: sftp-repo-creds } |
Names the Secret holding the key + known_hosts above. Same namespace as the Repository; a ClusterRepository adds namespace:. |
SSH has no cloud-IAM federation, so SFTP's auth is Secret-only — there is
no workloadIdentity here. A stray auth.workloadIdentity is silently
pruned by the API server (it's not in the schema), not rejected.
Customization — the values you actually change¶
host/port/path/username— the connection coordinates.KOPIA_SFTP_KNOWN_HOSTS— re-runssh-keyscanand update this if the server is rebuilt or its host key rotates.create.enabled— initialize the repository if missing.
As a ClusterRepository¶
The same backend.sftp stanza works on a cluster-scoped
ClusterRepository; every
Secret reference must carry an explicit namespace: and the Secret (key +
known_hosts + password) must exist where the movers run — see Movers.
Try it end-to-end¶
Prove this backend really takes a backup. The same example file carries a tiny
smoke-test (a throwaway PVC + a SnapshotPolicy + a Snapshot) that targets the
sftp-primary repository above, so you can go from "applied" to "a snapshot on
my server" in one arc.
Fill in the credentials first
The smoke backup only goes green once KOPIA_SFTP_KEY_DATA /
KOPIA_SFTP_KNOWN_HOSTS are a real key and host-key line. With the
REPLACE_ME placeholders the Repository stalls at Failed (kopia can't reach
the server) and the Snapshot stays Pending.
1. Apply the bundle (namespace backups, Secret, Repository, and the
smoke-test objects):
2. Wait for the repository to be Ready — the gate everything else waits on:
$ kubectl -n backups wait --for=condition=Ready repository/sftp-primary --timeout=2m
repository.kopiur.home-operations.com/sftp-primary condition met
3. Take the smoke backup. The Snapshot uses generateName, so create it
(the namespace, Secret, Repository, PVC, and policy already exist and report
unchanged — the Snapshot is the one new object):
$ kubectl create -f deploy/examples/backends/sftp.yaml
snapshot.kopiur.home-operations.com/smoke-now-abc12 created
4. Watch it succeed:
$ kubectl -n backups get snapshots -w
NAME PHASE ORIGIN SNAPSHOT AGE
smoke-now-abc12 Pending manual 2s
smoke-now-abc12 Running manual 7s
smoke-now-abc12 Succeeded manual k1f1ec0a8 38s
(Output illustrative.) The Snapshot has no fixed Succeeded condition; to
wait on it in a script, key on the phase:
$ kubectl -n backups wait --for=jsonpath='{.status.phase}'=Succeeded \
snapshot/smoke-now-abc12 --timeout=5m
5. Deep proof — the data really moved. status.stats shows non-zero
bytesNew/filesNew, and status.snapshot.kopiaSnapshotID is the kopia
snapshot ID on your server:
$ kubectl -n backups get snapshot smoke-now-abc12 -o jsonpath='{.status.stats}'
{"sizeBytes":4096,"bytesNew":1280,"filesNew":2,"filesUnchanged":0}
$ kubectl -n backups get snapshot smoke-now-abc12 -o jsonpath='{.status.snapshot.kopiaSnapshotID}'
k1f1ec0a8
(Both outputs illustrative — sizes and the ID vary.) Non-zero bytesNew is the
proof the backup uploaded real content over SFTP.
6. Clean up the smoke-test when you're done:
$ kubectl -n backups delete snapshot --all # finalizer also deletes the kopia snapshot
$ kubectl -n backups delete snapshotpolicy smoke
$ kubectl -n backups delete pvc smoke-data
Deleting a Snapshot deletes its snapshot
A produced Snapshot defaults to deletionPolicy: Delete, so removing the CR
runs kopia snapshot delete via a finalizer. Use Retain (or Orphan) to keep
the data — see Backups → deletionPolicy.
From here the full lifecycle is backend-independent — only the Repository
differs. Put it on a cron with a SnapshotSchedule
(Backups & schedules,
Example 01) and restore by
picking a Snapshot (Restores,
Example 03).
Troubleshooting¶
Host-key mismatch
If KOPIA_SFTP_KNOWN_HOSTS is empty, wrong, or stale (server rebuilt), the
connection is rejected — Kopiur won't trust-on-first-use. Re-run
ssh-keyscan -p <port> <host> and update the Secret. Match the port you actually
use.
Non-standard port? The known_hosts format changes
On any port other than 22, the known_hosts host field is written
[host]:port, brackets included:
ssh-keyscan -p 2222 nas.lan emits exactly that form — copy its output verbatim.
A plain nas.lan ... line will not match a port-2222 connection, and the
failure looks identical to a wrong host key.
Dashed Secret keys are dropped
Do not name the key ssh-privatekey — envFrom drops dashed keys, so the
mover would see no key and connect with none. Use KOPIA_SFTP_KEY_DATA.
permission denied (publickey)— the public key isn't in the server'sauthorized_keys, or the private key underKOPIA_SFTP_KEY_DATAis malformed (clipped, re-indented, wrong key).- Writes fail after connecting — the SSH user can't write
path; fix ownership/permissions on the server.
See also¶
- Repositories & backends — concepts: scope, encryption, creation.
- Permissions, UID & GID — server-side ownership of the repo path.
- Movers, RBAC & credentials — where the credential Secret must live.
- Sibling backend: filesystem — the same NAS as a mounted volume.