atoll PDS

Getting started

Installation

What Atoll needs on a machine, how to install it for development, and how to build the release you ship to a server.

Requirements#

Component Requirement
Elixir ~> 1.17 (declared in mix.exs)
Erlang/OTP The version your Elixir build targets
PostgreSQL 16 or newer; 18 recommended
SQLite Optional alternative to PostgreSQL, single node only
Bun Builds the React account frontend through mix assets.build
Docker Optional; ghcr.io/tsirysndr/atoll-pds is a prebuilt SQLite image
Nix Optional; flake.nix provides dev shells and release builds

Backups need matching PostgreSQL client binaries on PATH: pg_dump 14 cannot dump an 18 server.

Install the toolchain#

Install Elixir and Erlang/OTP with your platform's package manager or a version manager such as asdf or mise. Verify both:

elixir --version

PostgreSQL#

Install PostgreSQL 16+ and create a role and database. For a production host:

sudo -u postgres createuser --pwprompt atoll
sudo -u postgres createdb --owner=atoll atoll_prod

Atoll needs an ordinary owner role; no extensions beyond plpgsql are required. Use TLS (?ssl=true in DATABASE_URL) whenever the database is not on the same host.

SQLite#

SQLite is supported for a single-node PDS. The adapter is chosen at build time, so ATOLL_DATABASE=sqlite must be set for every Mix command including release builds. Switching adapters requires recompilation and does not convert an existing database; SQLite uses its own _build/sqlite directory so adapter-specific artifacts stay separate. See SQLite for the durability and concurrency trade-offs.

Install the project#

git clone https://github.com/tsirysndr/atoll.git
cd atoll
mix setup

mix setup expands to deps.get, ecto.create, ecto.migrate, the seed script, and assets.setup + assets.build. The last two install and build the React frontend with Bun; see Account frontend.

Development uses atoll_dev.sqlite3 in the project directory when the SQLite adapter is selected; tests use atoll_test.sqlite3, with MIX_TEST_PARTITION appended when it is set. Override the filename with DATABASE_PATH, using a different file per test partition. Database files and journals are ignored by Git.

Run with Docker#

ghcr.io/tsirysndr/atoll-pds is built from the repository Dockerfile with the SQLite adapter, so it is a single-node PDS: exactly one container may write to a given database. Images are published for linux/amd64 and linux/arm64 on every v* tag and on manual dispatch, tagged latest, the version, and the commit SHA.

docker pull ghcr.io/tsirysndr/atoll-pds:latest
docker build -t atoll-pds .            # the same image, from a checkout

The container listens on port 4000, runs as uid 65534, keeps the database at DATABASE_PATH (/data/atoll.sqlite3 by default), and runs migrations before starting the release. Put the boot-required variables in an env file — at minimum SECRET_KEY_BASE, ATOLL_KEY_ENCRYPTION_KEY, ATOLL_SESSION_SIGNING_KEY, PHX_HOST, ATOLL_PDS_DID, and ATOLL_AVAILABLE_USER_DOMAINS — and start it:

docker volume create atoll-data

docker run -d --name atoll \
  --env-file atoll.env \
  --publish 127.0.0.1:4000:4000 \
  --volume atoll-data:/data \
  ghcr.io/tsirysndr/atoll-pds:latest

ATOLL_DATABASE=sqlite is compiled into the image and boot refuses to start if it is changed; DATABASE_URL and READ_DATABASE_URL do not apply. Everything else in Setup environment does, including TLS termination at a reverse proxy in front of the container.

Mount a volume at /data#

/data is the only writable state in the image and holds the whole PDS: atoll.sqlite3, its -wal write-ahead log, and its -shm shared-memory index. Without a mount they live in the container's own layer and are destroyed with it, losing every account, repository, and signing key. With the default ATOLL_BLOB_STORAGE=postgres (database-backed blobs) uploads grow this volume too, so size it for the blob quotas you allow.

A named volume is the simplest durable choice; docker volume inspect atoll-data locates it on the host. For a bind mount, create the directory and give it to uid 65534 first, otherwise the entrypoint exits reporting an unwritable DATABASE_PATH:

sudo install -d -o 65534 -g 65534 -m 0750 /srv/atoll
docker run -d --name atoll --env-file atoll.env \
  --publish 127.0.0.1:4000:4000 \
  --volume /srv/atoll:/data \
  ghcr.io/tsirysndr/atoll-pds:latest

Point DATABASE_PATH at another filename inside the mount if you prefer, for example DATABASE_PATH=/data/pds.sqlite3; keep it under /data, since a path elsewhere is not persisted. Exactly one container may run against one volume — SQLite has a single writer, and replacing a container means stopping the old one first.

Back up the volume as described in SQLite. The image has no sqlite3 binary, so run .backup from the host against the volume directory, or stop the container and copy all three files together. Back up ATOLL_KEY_ENCRYPTION_KEY with it; the database is unusable without that key.

Other container commands#

Any release command works as the container command, and ATOLL_SKIP_MIGRATIONS=true leaves migrations to a separate run:

docker run --rm --env-file atoll.env --volume atoll-data:/data \
  ghcr.io/tsirysndr/atoll-pds:latest eval 'Atoll.Release.migrate()'

The image ships a release, not Mix, so the mix atoll.* maintenance tasks in Operations are unavailable inside it. Run them from a checkout built with ATOLL_DATABASE=sqlite against the same database file.

Build with Nix#

flake.nix pins Erlang/OTP 28, Elixir 1.19 and Bun, so neither the shell nor the build downloads a toolchain. The adapter is compiled in, so there is one target per database:

nix develop            # SQLite shell: ATOLL_DATABASE=sqlite, sqlite3, mix2nix
nix develop .#postgres # PostgreSQL shell, with the matching psql and pg_dump

nix build .#sqlite     # release at ./result/bin/atoll
nix build .#postgres

nix build with no target builds the SQLite release. Hex dependencies are pinned in nix/deps.nix, generated from mix.lock; regenerate it with nix run nixpkgs#mix2nix > nix/deps.nix whenever mix.lock changes, or the build keeps using the old versions. The nix workflow builds both targets on manual dispatch.

The shells set EXQLITE_USE_SYSTEM so the SQLite driver compiles against nixpkgs' SQLite instead of downloading a prebuilt NIF. The frontend bundle is built from assets/bun.lock through nix/bun-deps.nix, so the sandboxed build needs no network; regenerate that file with python3 scripts/bun2nix.py after changing dependencies.

Build a production release#

Build from a clean checkout of the exact commit you validated in CI, on the same OS and architecture as the server (or on the server itself):

MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

For SQLite:

ATOLL_DATABASE=sqlite MIX_ENV=prod mix release

Copy _build/prod/rel/atoll to the server — for example /opt/atoll/releases/<git-sha> with a /opt/atoll/current symlink. Record the git commit hash; recovery sets ask for it.

Migrate and start#

Run migrations before starting the release:

bin/atoll eval 'Atoll.Release.migrate()'

With SQLite, set DATABASE_PATH to an absolute path whose parent directory exists and is writable by the service account.

Next steps#

Type to search across every page.