<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/camatcode/ex_ftp/refs/heads/main/assets/ex_ftp-logo-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/camatcode/ex_ftp/refs/heads/main/assets/ex_ftp-logo-light.png">
<img alt="ex_ftp logo" src="https://raw.githubusercontent.com/camatcode/ex_ftp/refs/heads/main/assets/ex_ftp-logo-light.png" width="320">
</picture>
</p>
<p align="center" id="top">
An extendable, lightweight FTP server with cloud integrations already built in
</p>
<p align="center">
<a href="https://hex.pm/packages/ex_ftp">
<img alt="Hex Version" src="https://img.shields.io/hexpm/v/ex_ftp.svg">
</a>
<a href="https://hexdocs.pm/ex_ftp">
<img alt="Hex Docs" src="http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat">
</a>
<a href="https://opensource.org/licenses/Apache-2.0">
<img alt="Apache 2 License" src="https://img.shields.io/hexpm/l/oban">
</a>
<a href="https://github.com/camatcode/ex_ftp/actions?query=branch%3Amain++">
<img alt="ci status" src="https://github.com/camatcode/ex_ftp/workflows/ci/badge.svg">
</a>
<a href='https://coveralls.io/github/camatcode/ex_ftp?branch=main'>
<img src='https://coveralls.io/repos/github/camatcode/ex_ftp/badge.svg?branch=main' alt='Coverage Status' />
</a>
<a href="https://mastodon.social/@scrum_log" target="_blank" rel="noopener noreferrer">
<img alt="Mastodon Follow" src="https://img.shields.io/badge/mastodon-%40scrum__log%40mastodon.social-purple?color=6364ff">
</a>
</p>
## Table of Contents
- [Installation](#installation)
- [Reckless Quick Start](#reckless-quick-start)
- [Configuration](#configuration)
- [Server Config](#1-server-config)
- [Choosing an Authenticator](#2-choose-an-authenticator)
- [Choosing a Storage Connector](#3-choose-a-storage-connector)
- [Authenticators](#authenticators)
- [No Auth](#authenticator-no-auth)
- [Passthrough Auth](#authenticator-passthrough-auth)
- [HTTP Basic Auth](#authenticator-http-basic-auth)
- [HTTP Digest Access Auth](#authenticator-http-digest-access-auth)
- [Bearer Token Auth](#authenticator-bearer-token-auth)
- [Webhook Auth](#authenticator-webhook-auth)
- [Custom Auth](#authenticator-custom-auth)
- [Storage Connectors](#storage-connectors)
- [File](#storage-connector-file)
- [S3](#storage-connector-s3)
- [Using Minio or LocalStack](#using-minio-or-localstack)
- [Others through S3Proxy](#storage-connector-others-through-s3proxy)
- [Custom Storage Connector](#custom-storage-connector)
- [Technical Details](#technical-details)
- [Supported Commands](#supported-commands)
- [Notes about Fly.io](#notes-about-flyio)
- [Special Thanks](#special-thanks)
## Installation
Add `:ex_ftp` to your list of deps in `mix.exs`:
```elixir
{:ex_ftp, "~> 1.0"}
```
Then run `mix deps.get` to install ExFTP and its dependencies.
## Reckless Quick Start
* Configure ex_ftp
* to use the file system,
* start on port 4040,
* don't include auth
```elixir
config :ex_ftp,
ftp_port: "FTP_PORT" |> System.get_env("4040") |> String.to_integer(),
min_passive_port: "MIN_PASSIVE_PORT" |> System.get_env("40002") |> String.to_integer(),
max_passive_port: "MAX_PASSIVE_PORT" |> System.get_env("40007") |> String.to_integer(),
authenticator: ExFTP.Auth.NoAuth,
authenticator_config: %{},
storage_connector: ExFTP.Storage.FileConnector
```
* Run `mix run --no-halt`
```
17:13:22.110 [info] Accepting connections on port 4040
```
* Connect using `ftp`
```bash
➜ ~ ftp localhost -p 4040
Connected to localhost.
220 Hello from ExFTP.
Name (localhost:cam):
331 User name okay, need password.
Password:
502 Command not implemented.
ftp: Login failed
ftp> ls
229 Entering Extended Passive Mode (|||40002|)
150 Here comes the directory listing.
lr--r--r-- 1 0 0 7 Feb 16 2024 bin -> usr/bin
dr--r--r-- 1 0 0 4096 May 13 2025 boot
dr--r--r-- 1 0 0 4096 Feb 16 2024 cdrom
dr--r--r-- 1 0 0 4680 May 20 2025 dev
dr--r--r-- 1 0 0 12288 May 19 2025 etc
dr--r--r-- 1 0 0 4096 Mar 25 2025 home
lr--r--r-- 1 0 0 7 Feb 16 2024 lib -> usr/lib
dr--r--r-- 1 0 0 4096 Feb 06 2025 lib32
lr--r--r-- 1 0 0 9 Feb 16 2024 lib64 -> usr/lib64
dr--r--r-- 1 0 0 4096 Feb 06 2025 libx32
d---r--r-- 1 0 0 16384 Feb 16 2024 lost+found
dr--r--r-- 1 0 0 4096 Feb 29 2024 media
dr--r--r-- 1 0 0 4096 Jan 09 2024 mnt
drw-r--r-- 1 0 0 4096 Apr 24 2025 opt
dr--r--r-- 1 0 0 0 May 02 2025 proc
d---r--r-- 1 0 0 4096 Mar 25 2025 root
dr--r--r-- 1 0 0 1580 May 17 2025 run
lr--r--r-- 1 0 0 8 Feb 16 2024 sbin -> usr/sbin
dr--r--r-- 1 0 0 4096 Jan 09 2024 srv
----r--r-- 1 0 0 2147483648 Feb 16 2024 swapfile
dr--r--r-- 1 0 0 0 May 02 2025 sys
dr--r--r-- 1 0 0 4096 May 19 2025 timeshift
drw-r--r-- 1 0 0 20480 May 20 2025 tmp
dr--r--r-- 1 0 0 4096 Apr 25 2025 usr
dr--r--r-- 1 0 0 4096 Mar 25 2025 var
226 Directory send OK.
ftp> ...
```
* Now, [properly configure it](#configuration).
-------
## Configuration
### 1. Server Config
Here is a detailed, example configuration.
```elixir
config :ex_ftp,
# port to run on
ftp_port: 21,
# optional, reports "Hello from {server_name}" on login
server_name: :ExFTP,
# the address this server binds to (default: 127.0.0.1)
ftp_addr: System.get_env("FTP_ADDR", "127.0.0.1"),
# FTP uses temporary, negotiated ports for certain commands called passive ports
# Choose the min and max range for these ports
# This range would represent how many of these certain commands can run at the same time.
# Be aware, too few options could create bottlenecks
min_passive_port: System.get_env("MIN_PASSIVE_PORT", "40002") |> String.to_integer(),
max_passive_port: System.get_env("MAX_PASSIVE_PORT", "40012") |> String.to_integer(),
# See "Choose an Authenticator"
authenticator: ExFTP.Auth.BasicAuth,
authenticator_config: %{
# used to login
login_url: "https://httpbin.dev/basic-auth/",
login_method: :get,
# used to verify the user is still considered valid (optional)
authenticated_url: "https://httpbin.dev/hidden-basic-auth/",
authenticated_method: :get,
authenticated_ttl_ms: 1000 * 60 * 60
},
# See "Choose a Storage Connector"
storage_connector: ExFTP.Storage.FileConnector,
storage_config: %{}
```
### 2. Choose an Authenticator
An `ExFTP.Authenticator` validates credentials when an FTP client sends a `USER` and `PASS` command.
Each authenticator is referenced in the `ex_ftp` config under the `authenticator` key.
Additionally, many require a map under `authenticator_config`.
### 3. Choose a Storage Connector
An `ExFTP.StorageConnector` provides access to your chosen storage provider - with the FTP business abstracted away.
Each storage connector is referenced in the `ex_ftp` config under the `storage_connector` key.
Additionally, many require a map under `storage_config`.
-------
## Authenticators
Below are all the included authenticators.
### Authenticator: No Auth
> [!WARNING]
> This is not recommended for any production server.
When **authenticator** is `ExFTP.Auth.NoAuth`, this authenticator will completely ignore any supplied credentials and
assume everything is authenticated.
```elixir
config :ex_ftp,
#....
authenticator: ExFTP.Auth.NoAuth,
authenticator_config: %{}
```
-------
### Authenticator: Passthrough Auth
> [!WARNING]
> This is not recommended for any production server.
When **authenticator** is `ExFTP.Auth.PassthroughAuth`, this authenticator will require credentials,
but accepts any user and password combination who isn't `root`.
```elixir
config :ex_ftp,
#....
authenticator: ExFTP.Auth.PassthroughAuth,
authenticator_config: %{}
```
[^ top](#top)
-------
### Authenticator: HTTP Basic Auth
> [!WARNING]
> This is not recommended for situations not protected by SSL.
When **authenticator** is `ExFTP.Auth.BasicAuth`, this authenticator will call out to an HTTP endpoint that implements
[HTTP Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) with the user's supplied credentials.
```elixir
config :ex_ftp,
#....
authenticator: ExFTP.Auth.BasicAuth,
authenticator_config: %{
# used to login
login_url: "https://httpbin.dev/basic-auth/",
login_method: :get,
# used to verify the user is still considered valid (optional)
authenticated_url: "https://httpbin.dev/hidden-basic-auth/",
authenticated_method: :get,
authenticated_ttl_ms: 1000 * 60 * 60
}
```
If the endpoint responds with **HTTP 200**, the user is considered authenticated.
Additionally, if configured, ex_ftp can call out to a separate endpoint that performs basic auth to check that a user
is still considered valid.
[^ top](#top)
-------
### Authenticator: HTTP Digest Access Auth
> [!NOTE]
> This can be used in situations where SSL is not available, though be warned, Digest Access is considered
> an obsolete protocol.
When **authenticator** is `ExFTP.Auth.DigestAuth`, this authenticator will call out to an HTTP endpoint that
implements [HTTP Digest Access Auth](https://en.wikipedia.org/wiki/Digest_access_authentication) with the user's
supplied credentials.
```elixir
config :ex_ftp,
#....
authenticator: ExFTP.Auth.DigestAuth,
authenticator_config: %{
# used to login
login_url: "https://httpbin.dev/basic-auth/",
login_method: :get,
# used to verify the user is still considered valid (optional)
authenticated_url: "https://httpbin.dev/hidden-basic-auth/",
authenticated_method: :get,
authenticated_ttl_ms: 1000 * 60 * 60
}
```
If, after completing the full workflow, the endpoint responds with **HTTP 200**, the user is considered authenticated.
Additionally, if configured, ex_ftp can call out to a separate endpoint that performs digest auth to check that a user
is still considered valid.
[^ top](#top)
-------
### Authenticator: Bearer Token Auth
> [!NOTE]
> This is helpful when the "user" is actually a system or process.
>
> `username` isn't important for a Bearer token; though a provided username is still held on to.
When **authenticator** is `ExFTP.Auth.BearerAuth`, this authenticator will call out to an HTTP endpoint that implements
[Bearer Tokens](https://swagger.io/docs/specification/v3_0/authentication/bearer-authentication/) with the user's
supplied credentials.
```elixir
config :ex_ftp,
#....
authenticator: ExFTP.Auth.BearerAuth,
authenticator_config: %{
# used to login
login_url: "https://httpbin.dev/bearer",
login_method: :post,
# used to verify the user is still considered valid (optional)
authenticated_url: "https://httpbin.dev/bearer",
authenticated_method: :post,
authenticated_ttl_ms: 1000 * 60 * 60
}
```
If the endpoint responds with **HTTP 200**, the user is considered authenticated.
Additionally, if configured, ex_ftp can call out to a separate endpoint that performs bearer auth to check that a user
is still considered valid.
[^ top](#top)
-------
### Authenticator: Webhook Auth
> [!NOTE]
> `password_hash` is the hash of the supplied password using the hashing algorithm dictated by the config.
When **authenticator** is `ExFTP.Auth.WebhookAuth`, this authenticator will call out to an HTTP endpoint that accepts
two query parameters: `username` and/or `password_hash`.
```elixir
config :ex_ftp,
#....
authenticator: ExFTP.Auth.WebhookAuth,
authenticator_config: %{
# used to login
login_url: "https://httpbin.dev/status/200",
login_method: :post,
# affects the output of the `password_hash` query parameter
# accepts anything that :crypto can handle
password_hash_type: :sha256,
# used to verify the user is still considered valid (optional)
authenticated_url: "https://httpbin.dev/status/200",
authenticated_method: :post,
authenticated_ttl_ms: 1000 * 60 * 60
}
```
If the endpoint responds with **HTTP 200**, the user is considered authenticated.
Additionally, if configured, ex_ftp can call out to a separate endpoint that performs webhook auth to check that a user
is still considered valid.
[^ top](#top)
-------
### Authenticator: Custom Auth
Creating your own Authenticator is simple - just implement the `ExFTP.Authenticator` behaviour.
```elixir
# SPDX-License-Identifier: Apache-2.0
defmodule MyCustomAuth do
alias ExFTP.Authenticator
@behaviour Authenticator
@impl Authenticator
@spec valid_user?(username :: Authenticator.username()) :: boolean
def valid_user?(username) do
# return true if the username is valid
# return false if invalid
# this short-circuits bad login requests,
# if it would take longer than 5 seconds to validate a username
# then its best to just return true
# as there wouldn't be a performance benefit
end
@impl Authenticator
@spec login(
password :: Authenticator.password(),
authenticator_state :: Authenticator.authenticator_state()
) :: {:ok, Authenticator.authenticator_state()} | {:error, term()}
def login(password, authenticator_state) do
# authenticator_state may have the key :username
# perform initial login
# return {:ok, current_authenticator_state} if successful
# authenticator_state is passed around during the session
# your authenticated?/1 may want this method to put
# something about the password in the state
# return {:error, anything} if unsuccessful
end
@impl Authenticator
@spec authenticated?(
authenticator_state :: Authenticator.authenticator_state()
) :: boolean()
def authenticated?(authenticator_state), do
# re-check that a user is authenticated
# return {:ok, current_authenticator_state} if successful
# authenticator_state is passed around during the session
# return {:error, anything} if unsuccessful
end
end
```
[^ top](#top)
-------
## Storage Connectors
Below are all the included storage connectors.
### Storage Connector: File
When **storage_connector** is `ExFTP.Storage.FileConnector`, this connector will use the file system of where
it is running.
This is the out-of-the-box behavior you'd expect from any FTP server.
```elixir
config :ex_ftp,
#....
storage_connector: ExFTP.Storage.FileConnector,
storage_config: %{}
```
[^ top](#top)
-------
### Storage Connector: S3
When **storage_connector** is `ExFTP.Storage.S3Connector`, this connector will use any S3-compatible storage provider.
Underneath the hood, ex_ftp is using `ExAws.S3`, so you'll need that configured properly.
```elixir
# ExAws is pretty smart figuring out S3 credentials of the system
# For me, I had to include the region.
# Consult the ExAws docs for more
config :ex_aws,
region: {:system, "AWS_REGION"}
config :ex_ftp,
#....
storage_connector: ExFTP.Storage.S3Connector,
storage_config: %{
# the `/` path of the FTP server will point to s3://{my-storage-bucket}/
storage_bucket: "my-storage-bucket"
}
```
#### Using Minio or LocalStack
Minio is a popular open-source, self-hosted alternative to AWS S3.
LocalStack is a popular way to test AWS without connecting to AWS.
The only difference in config will be how you configure `ExAws`.
Here's an example with minio where we're changing the credentials and endpoint
```elixir
# Assuming:
# we're connecting to a minio @ https://my.minio.example.com:9000/
# there exists a $MINIO_ACCESS or $AWS_ACCESS_KEY_ID in system env
# there exists a $MINIO_SECRET or $AWS_SECRET_ACCESS_KEY in system env
config :ex_aws,
access_key_id: [
{:system, "MINIO_ACCESS"},
{:system, "AWS_ACCESS_KEY_ID"},
:instance_role
],
secret_access_key: [
{:system, "MINIO_SECRET"},
{:system, "AWS_SECRET_ACCESS_KEY"},
:instance_role
],
s3: [
scheme: "https://",
host: "my.minio.example.com",
port: 9000,
region: "us-east-1"
]
config :ex_ftp,
#....
storage_connector: ExFTP.Storage.S3Connector,
storage_config: %{
# the `/` path of the FTP server will point to s3://{my-storage-bucket}/
storage_bucket: "my-storage-bucket"
}
```
[^ top](#top)
-------
### Storage Connector: Others through S3Proxy
For other storage providers (Google Cloud, Azure Storage, etc.), it's probably best to deploy a proxy that translates
S3 requests into requests to those providers, then use the `ExFTP.Storage.S3Connector` to connect to that proxy.
* See [S3Proxy](https://github.com/gaul/s3proxy?tab=readme-ov-file)
[^ top](#top)
-------
### Custom Storage Connector
Creating your own Storage Connector is simple - just implement the `ExFTP.StorageConnector` behaviour.
```elixir
# SPDX-License-Identifier: Apache-2.0
defmodule MyStorageConnector do
@moduledoc false
@behaviour ExFTP.StorageConnector
alias ExFTP.StorageConnector
@impl StorageConnector
@spec get_working_directory(connector_state :: StorageConnector.connector_state()) ::
String.t()
def get_working_directory(%{current_working_directory: cwd} = _connector_state) do
# returns the current directory, for most cases this is just a pass through
# however, you might want to modify what the current directory is
# based on some state
end
@impl StorageConnector
@spec directory_exists?(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) :: boolean
def directory_exists?(path, _connector_state) do
# Given a path, does this directory exist in storage?
end
@impl StorageConnector
@spec make_directory(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) :: {:ok, StorageConnector.connector_state()} | {:error, term()}
def make_directory(path, connector_state) do
# Given a path, make a directory
# For S3-like connectors, a "directory" doesn't really exist
# so those connectors typically keep track of virtual directories
# that we're created by user during the session
# if they're unused, they aren't persisted.
end
@impl StorageConnector
@spec delete_directory(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) :: {:ok, StorageConnector.connector_state()} | {:error, term()}
def delete_directory(path, connector_state) do
# Give a path, delete the directory
end
@impl StorageConnector
@spec delete_file(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) :: {:ok, StorageConnector.connector_state()} | {:error, term()}
def delete_file(path, connector_state) do
# Give a path, delete the file
end
@impl StorageConnector
@spec get_directory_contents(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) ::
{:ok, [StorageConnector.content_info()]} | {:error, term()}
def get_directory_contents(path, connector_state) do
# returns a list of content_infos
# the model for them was inspired by File.lstat()
# Have a look at StorageConnector.content_info type
end
@impl StorageConnector
@spec get_content_info(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) ::
{:ok, StorageConnector.content_info()} | {:error, term()}
def get_content_info(path, _connector_state) do
# given a path, return information on the file/directory there
# Have a look at StorageConnector.content_info type
end
@impl StorageConnector
@spec get_content(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state()
) :: {:ok, any()} | {:error, term()}
def get_content(path, _connector_state) do
# Return a {:ok, stream} of path
end
@impl StorageConnector
@spec create_write_func(
path :: StorageConnector.path(),
connector_state :: StorageConnector.connector_state(),
opts :: list()
) :: function()
def create_write_func(path, connector_state, opts \\ []) do
# Return a function that will write `stream` to your storage at path
# e.g
# fn stream ->
# fs = File.stream!(path)
#
# try do
# _ =
# stream
# |> chunk_stream(opts)
# |> Enum.into(fs)
#
# {:ok, connector_state}
# rescue
# _ ->
# {:error, "Failed to transfer"}
# end
#end
end
end
```
[^ top](#top)
-------
## Technical Details
### Supported Commands
- General
- `QUIT`
- `SYST`
- `TYPE <mode>`
- `PASV`
- `EPSV`
- `EPRT <eport_info>`
- Auth
- `USER <username>`
- `PASS <password>`
- Storage
- `PWD`
- `CDUP`
- `CWD <path>`
- `MKD <path>`
- `RMD <path>`
- `DELE <path>`
- `LIST`
- `LIST -a`
- `LIST -a <path>`
- `LIST <path>`
- `NLST`
- `NLST -a`
- `NLST -a <path>`
- `NLST <path>`
- `RETR <path>`
- `SIZE <path>`
- `STOR <path>`
See `ExFTP.Storage.Common` for more information.
### Notes about Fly.io
If you're wanting to deploy onto Fly.io, you'll quickly discover an issue with passive ports.
Fly wants you to enumerate all ports that your server will use, fine; however, it takes the assumption
that these ports will be open *on start* and will *remain* open.
FTP passive ports are temporary and negotiated. Fly hates this and assumes something is going wrong.
Be careful.
[^ top](#top)
-----
## Special Thanks
The initial funding for this code came from [StudioCMS.io](https://studiocms.io/).
Its first closed-source implementation came from [Jake Stover](https://github.com/jwstover) and expanded by the
entire team at StudioCMS.
Furthermore, StudioCMS's leadership allowed me to clean it up, generalize it, and open source it.
Thanks!
[^ top](#top)