This guide explains how to build your own backend for Sftpd.
If you only need a built-in backend, see Backends. If you want
the exact callback contracts, see Sftpd.Backend.
Backend Model
Sftpd asks a backend to present a filesystem-like interface over some storage
system. That storage can be:
- a local service API
- object storage
- a database
- an in-memory structure
- a process that fronts another system
Your backend does not need to be a real filesystem, but it does need to act like one from the SFTP client's point of view.
Required Callbacks
Every backend must implement:
init/1list_dir/2file_info/2make_dir/2del_dir/2delete/2rename/3read_file/2write_file/3
Those callbacks are enough for a working backend, even if the underlying implementation is simplistic.
Minimal Example
defmodule MyApp.ExampleBackend do
@behaviour Sftpd.Backend
@impl true
def init(opts) do
{:ok, %{root: Keyword.fetch!(opts, :root)}}
end
@impl true
def list_dir(_path, _state) do
{:ok, [~c".", ~c".."]}
end
@impl true
def file_info(_path, _state) do
{:error, :enoent}
end
@impl true
def make_dir(_path, _state), do: :ok
@impl true
def del_dir(_path, _state), do: :ok
@impl true
def delete(_path, _state), do: :ok
@impl true
def rename(_src, _dst, _state), do: :ok
@impl true
def read_file(_path, _state), do: {:error, :enoent}
@impl true
def write_file(_path, _content, _state), do: :ok
endReturning File Metadata
file_info/2 must return Erlang-style file metadata tuples. In practice you
should use the helpers in Sftpd.Backend instead of constructing them by hand:
Example:
{:ok, Sftpd.Backend.file_info(byte_size(content), NaiveDateTime.to_erl(mtime))}For root-like paths, make sure you return directory metadata rather than
{:error, :enoent}.
Path Handling
SFTP paths arrive as charlists. Common helpers:
normalize_path/1 is especially useful for key-based stores such as S3-like
systems because it removes the leading /.
Example: Local Folder Backend
This example maps SFTP paths into a single root directory on local disk. The
important part is the local_path/2 helper: it normalizes SFTP charlist paths,
rejects .. traversal, and uses a path-relative containment check that also
works when the configured root is /. This example does not resolve symlink
targets; if users can create symlinks inside the root, disallow symlinks or add
real-path validation before using this pattern for untrusted writes.
defmodule MyApp.LocalFolderBackend do
@behaviour Sftpd.Backend
alias Sftpd.Backend
@impl true
def init(opts) do
root = opts |> Keyword.fetch!(:root) |> Path.expand()
File.mkdir_p!(root)
{:ok, %{root: root}}
end
@impl true
def list_dir(path, state) do
with {:ok, local} <- local_path(path, state),
{:ok, entries} <- File.ls(local) do
{:ok, [~c".", ~c".." | Enum.map(entries, &String.to_charlist/1)]}
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def file_info(path, state) do
with {:ok, local} <- local_path(path, state),
{:ok, stat} <- File.stat(local) do
{:ok, stat_to_file_info(stat)}
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def make_dir(path, state) do
with {:ok, local} <- local_path(path, state),
:ok <- File.mkdir(local) do
:ok
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def del_dir(path, state) do
with {:ok, local} <- local_path(path, state),
:ok <- File.rmdir(local) do
:ok
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def delete(path, state) do
with {:ok, local} <- local_path(path, state),
:ok <- File.rm(local) do
:ok
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def rename(src, dst, state) do
with {:ok, src_local} <- local_path(src, state),
{:ok, dst_local} <- local_path(dst, state),
:ok <- File.rename(src_local, dst_local) do
:ok
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def read_file(path, state) do
with {:ok, local} <- local_path(path, state),
{:ok, content} <- File.read(local) do
{:ok, content}
else
{:error, reason} -> {:error, map_error(reason)}
end
end
@impl true
def write_file(path, content, state) do
with {:ok, local} <- local_path(path, state),
:ok <- File.mkdir_p(Path.dirname(local)),
:ok <- File.write(local, content) do
:ok
else
{:error, reason} -> {:error, map_error(reason)}
end
end
defp local_path(path, %{root: root}) do
parts =
path
|> Backend.normalize_path()
|> Path.split()
|> Enum.reject(&(&1 in ["", "."]))
if ".." in parts do
{:error, :eacces}
else
candidate = Path.expand(Path.join([root | parts]))
if contained_in_root?(candidate, root) do
{:ok, candidate}
else
{:error, :eacces}
end
end
end
defp contained_in_root?(candidate, root) do
relative = Path.relative_to(candidate, root)
candidate == root or
(Path.type(relative) == :relative and
relative != ".." and
not String.starts_with?(relative, "../"))
end
defp stat_to_file_info(%File.Stat{type: :directory}) do
Backend.directory_info()
end
defp stat_to_file_info(%File.Stat{size: size, mtime: mtime}) do
Backend.file_info(size, mtime)
end
defp map_error(:enoent), do: :enoent
defp map_error(:eacces), do: :eacces
defp map_error(:enotdir), do: :enoent
defp map_error(:eexist), do: :eexist
defp map_error(:enotempty), do: :eexist
defp map_error(_reason), do: :eio
endUse it like any module backend:
Sftpd.start_server(
port: 2222,
backend: MyApp.LocalFolderBackend,
backend_opts: [root: "/srv/my_app/sftp"],
auth: {:passwords, [{"user", "pass"}]},
system_dir: "ssh_keys"
)Directory Listings
list_dir/2 must return entries as charlists and must include:
~c"."~c".."
Even if the backing store does not have explicit directory entries, the SFTP layer expects those names to exist.
Error Conventions
Prefer POSIX-style atoms:
:enoentfor missing files or directories:eaccesfor permission failures:einvalfor invalid requests:eiofor unexpected storage failures
Using stable error atoms matters because SFTP clients map them to user-visible status codes.
Optional Streaming Callbacks
For better large-file performance, module backends can also implement:
read_file_range/4begin_write/2write_chunk/4finish_write/2abort_write/2
These callbacks are optional, but valuable when:
- whole-file reads are too expensive
- uploads should stream rather than buffer
- multipart writes are supported by the target storage
If you do not implement them, Sftpd falls back to the required callbacks.
Process-Based Backends
If your backend already lives inside a GenServer, you can provide:
backend: {:genserver, MyApp.BackendServer, session: true}In that mode, Sftpd does not call init/1. Instead it sends handle_call/3
messages corresponding to the required backend operations.
The default {:genserver, server} form preserves the legacy process-backend
message contract. Use {:genserver, server, session: true} when the backend
needs authenticated session context in each call.
This is useful when:
- the backend owns pooled connections
- the backend has mutable shared state
- the backend is already part of your supervision tree
Process-based backends use only the required whole-file callback contract. The optional streaming callbacks are module-backend-only.
Here is a complete in-memory GenServer shape:
defmodule MyApp.SftpBackend do
use GenServer
alias Sftpd.Backend
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
{:ok, %{files: %{}}}
end
@impl true
def handle_call({:list_dir, _path, _session}, _from, state) do
names =
state.files
|> Map.keys()
|> Enum.map(&Path.basename/1)
|> Enum.uniq()
|> Enum.map(&String.to_charlist/1)
{:reply, {:ok, [~c".", ~c".." | names]}, state}
end
def handle_call({:file_info, path, _session}, _from, state) do
key = Backend.normalize_path(path)
reply =
case Map.fetch(state.files, key) do
{:ok, content} ->
mtime = NaiveDateTime.utc_now() |> NaiveDateTime.to_erl()
{:ok, Backend.file_info(byte_size(content), mtime)}
:error ->
{:error, :enoent}
end
{:reply, reply, state}
end
def handle_call({:make_dir, _path, _session}, _from, state), do: {:reply, :ok, state}
def handle_call({:del_dir, _path, _session}, _from, state), do: {:reply, :ok, state}
def handle_call({:delete, path, _session}, _from, state) do
{:reply, :ok, update_in(state.files, &Map.delete(&1, Backend.normalize_path(path)))}
end
def handle_call({:rename, src, dst, _session}, _from, state) do
src_key = Backend.normalize_path(src)
dst_key = Backend.normalize_path(dst)
case Map.pop(state.files, src_key) do
{nil, files} -> {:reply, {:error, :enoent}, %{state | files: files}}
{content, files} -> {:reply, :ok, %{state | files: Map.put(files, dst_key, content)}}
end
end
def handle_call({:read_file, path, _session}, _from, state) do
reply =
case Map.fetch(state.files, Backend.normalize_path(path)) do
{:ok, content} -> {:ok, content}
:error -> {:error, :enoent}
end
{:reply, reply, state}
end
def handle_call({:write_file, path, content, _session}, _from, state) do
key = Backend.normalize_path(path)
{:reply, :ok, put_in(state.files[key], content)}
end
endAdd the backend process to your application supervision tree before starting the SFTP server:
children = [
MyApp.SftpBackend,
MyApp.SftpServer
]Then point Sftpd at the registered process:
Sftpd.start_server(
port: 2222,
backend: {:genserver, MyApp.SftpBackend, session: true},
auth: {:passwords, [{"user", "pass"}]},
system_dir: "ssh_keys"
)Backend calls are synchronous from the SFTP client's perspective. If a
GenServer.call/3 blocks, the client operation blocks too.
Post-Write Processing with Broadway
Use Broadway for follow-up processing after the backend has durably accepted a file. Do not use it as the synchronous storage acknowledgement path unless the client can safely treat a queued message as durable storage.
def handle_call({:write_file, path, content, _session}, _from, state) do
:ok = MyStorage.put(path, content)
Broadway.producer_names(MyApp.SftpIngestBroadway)
|> Enum.each(fn producer ->
message = %Broadway.Message{data: %{path: path}}
Broadway.push_messages(producer, [message])
end)
{:reply, :ok, state}
endThe storage write happens before the reply. Broadway is then responsible for post-upload work such as parsing, indexing, thumbnails, notifications, or moving the file into a longer pipeline.
Running Under Supervision
Sftpd.child_spec/1 starts and stops the SSH daemon under your application
supervisor:
children = [
{Sftpd,
port: 2222,
backend: Sftpd.Backends.Memory,
backend_opts: [],
auth: {:passwords, [{"user", "pass"}]},
system_dir: "ssh_keys"}
]Authentication
Use auth: {:passwords, [{"username", "password"}]} for local development.
For production, pass auth: {MyApp.SftpAuth, opts} and implement
Sftpd.Auth.
Auth callbacks return a session map. Module callbacks can opt into that context by implementing session-aware arities, for example:
def list_dir(path, %{tenant_id: tenant_id}, state) do
list_tenant_dir(tenant_id, path, state)
endProcess backends receive the session as the final tuple element, such as
{:read_file, path, session}.
Known Semantics and Limitations
- SFTP paths are charlists.
- Non-streaming backends read and write whole files through the required callbacks.
- Process-based backends use synchronous
GenServer.call/3. - Process-based backends do not use optional streaming callbacks.
- OTP's stock SFTP server reports close success to the client even when a close-time backend flush fails, so close-only failures are logged server-side.
Testing Recommendations
At minimum, test:
- root listing behavior
- missing path behavior
- file metadata shape
- write then read round-trips
- rename semantics
- directory creation and deletion
If you implement streaming callbacks, also test:
- sequential reads through
read_file_range/4 - sequential writes through
write_chunk/4 - finalization and abort paths
- non-sequential write fallback behavior if relevant
Telemetry
Backend activity is visible through Sftpd telemetry events emitted around
server lifecycle and SFTP file-handler operations. See
Telemetry for the event catalog and metadata.
Next Steps
- See
Sftpd.Backendfor the exact callback contracts - See Backends for tradeoffs between built-in and custom backends
- See
Sftpd.Backends.Memoryfor a simple reference implementation - See
Sftpd.Backends.S3for a streaming-capable reference implementation