Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

tranquil-pds / docs / 2_INSTALL_CONTAINERS.md
14 kB

Tranquil PDS containerized production deployment#

This guide covers deploying Tranquil PDS using containers with podman.

  • Debian 13+: Uses systemd quadlets
  • Alpine 3.23+: Uses per-container OpenRC init scripts

Prerequisites#

  • A server :p
  • Disk space for blobs, around* 1GB per active user as a baseline
  • A domain name pointing to your server's IP
  • A wildcard TLS certificate for *.pds.example.com, since user handles are served as subdomains
  • Root/sudo/doas access

🦪 Lewis

  • "around" here meaning "at absolute least!"

Reverse proxy#

The bundled pod ships nginx as its reverse proxy, and this guide uses it throughout. nginx is just the default. Swap in whatever you prefer. Tranquil serves its API and web UI on a single port, so any reverse proxy works once it forwards to the app on [::1]:3000.

Caddy is one good option. It grabs & renews TLS certificates automatically, including the wildcard this setup needs (if you're lucky, which Lewis is not), so the manual certbot steps later become unnecessary.

Terminating TLS in Tranquil#

You can also skip the reverse proxy and let Tranquil terminate TLS itself. Set TLS_CERT_PATH and TLS_KEY_PATH (or the [server.tls] block in the config file) to your cert chain and private key.

Tranquil does not request or renew certs. Keep using certbot, acme.sh, lego, step, or whatever you're already using. After each renewal, send the process SIGHUP to reload the certificates and key. Live requests are unaffected and new connections pick up the new cert.

Client IP and forwarded headers#

Rate limiting and device records are based on the client IP ofc. Behind a reverse proxy, Tranquil reads it from X-Forwarded-For, counting hops from the right. You can set TRUSTED_PROXY_COUNT for how many proxies to trust. Leave it unset to let Tranquil assume the count. We assumes 1 proxy when something else terminates TLS, and 0 when Tranquil terminates TLS itself. At 0 it uses the direct conn address and ignores forwarded headers that a direct client could maliciously invent.

Quickstart (docker/podman compose)#

If you just want to get running quickly:

cp example.toml config.toml

Edit config.toml with your values. Generate secrets with openssl rand -base64 48.

docker-compose.prod.yaml pulls the prebuilt image from atcr.io. Sign in to the registry first with podman login atcr.io.

nginx will not start without a certificate, so create a temporary self-signed one, bring the stack up, then swap in a real wildcard cert:

mkdir -p certs
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
  -keyout certs/privkey.pem -out certs/fullchain.pem \
  -subj "/CN=pds.example.com"
podman-compose -f docker-compose.prod.yaml up -d

To build the image from source instead, run podman build -t atcr.io/tranquil.farm/tranquil-pds:latest . before bringing the stack up.

User handles are subdomains, so the real certificate must be a wildcard for *.pds.example.com, which requires DNS-01 validation. Follow the DNS cert steps in the Wildcard TLS certificate section below, then:

podman-compose -f docker-compose.prod.yaml restart nginx

Standalone container without compose#

If you already have postgres running on the host, you can run just the app container.

Pull the image. atcr.io requires authentication, so sign in first:

podman login atcr.io
podman pull atcr.io/tranquil.farm/tranquil-pds:latest

Run with host networking so it can reach postgres on localhost, and mount config + storage:

podman run -d --name tranquil-pds \
  --network=host \
  -v /etc/tranquil-pds/config.toml:/etc/tranquil-pds/config.toml:ro,Z \
  -v /var/lib/tranquil-pds:/var/lib/tranquil-pds:Z \
  atcr.io/tranquil.farm/tranquil-pds:latest

To build from source instead, run podman build -t atcr.io/tranquil.farm/tranquil-pds:latest . and use that tag.

Then point your reverse proxy at the app on port 3000 for every route. With nginx that looks like:

location /xrpc/ {
    proxy_pass http://[::1]:3000;
    # full proxy headers are in deploy/nginx/nginx-pod.conf
}

location / {
    proxy_pass http://[::1]:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

With Caddy the equivalent is a one-line reverse_proxy [::1]:3000, and it handles TLS for you. See deploy/nginx/nginx-pod.conf in the repo for the full nginx config with all routes.

The end!!!

Or wait, you want more? Perhaps a deployment that comes back on server restart?


Common setup#

Both service-managed deployments share the steps below. Do these first, then jump to the Debian or Alpine section for the init-specific stuff, and finish in the Wildcard TLS certificate section.

Install podman#

Debian:

apt update
apt install -y podman

Alpine:

apk update
apk add podman fuse-overlayfs
rc-update add cgroups
rc-service cgroups start

Create the directory structure#

mkdir -p /srv/tranquil-pds/{postgres,blobs,store,certs,acme,config}

Clone the repo and pull the image#

The repo provides the quadlet, OpenRC, and nginx files. The image comes prebuilt from atcr.io. Sign in before pulling:

cd /opt
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
podman login atcr.io
podman pull atcr.io/tranquil.farm/tranquil-pds:latest

To build from source instead, tag it with the same name so the service uses it:

cd tranquil-pds && podman build -t atcr.io/tranquil.farm/tranquil-pds:latest .

Create a configuration file#

cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
chmod 600 /srv/tranquil-pds/config/config.toml

Edit /srv/tranquil-pds/config/config.toml and fill in your values. Generate secrets with:

openssl rand -base64 48

🦪 Lewis

Every config option can also be set via an environment variable named in the comments in example.toml. Environment variables always take precedence over the config file.

Create the nginx config and database secret#

The bundled pod runs nginx as its proxy. The app and nginx share the pod's network namespace, so the proxy reaches the app on [::1]:3000:

cp /opt/tranquil-pds/deploy/nginx/nginx-pod.conf /srv/tranquil-pds/config/nginx.conf

Create the podman secret for the database password. Use the same password in the database.url of your config.toml:

echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -

Create a temporary TLS certificate#

The nginx that comes out of the box will not start without a certificate. The real wildcard cert needs DNS-01 validation and is set up once the stack is running, so for now let's add a self-signed placeholder so the services can start:

openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
  -keyout /srv/tranquil-pds/certs/privkey.pem \
  -out /srv/tranquil-pds/certs/fullchain.pem \
  -subj "/CN=pds.example.com"

If your proxy obtains its own certificates, like Caddy does, you can skip this step!

The app runs migrations itself on first boot btw, so there is no separate migration step if you're looking for one.


Debian with systemd quadlets#

Quadlets are a nice way to run podman containers under systemd.

Install quadlet definitions#

Copy the quadlet files from the repository:

mkdir -p /etc/containers/systemd
cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds.pod /etc/containers/systemd/
cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-db.container /etc/containers/systemd/
cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-app.container /etc/containers/systemd/
cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-nginx.container /etc/containers/systemd/

Optional quadlets for valkey and minio are also available in deploy/quadlets/ if you need them.

Start services#

systemctl daemon-reload
systemctl start tranquil-pds-db
sleep 10
systemctl start tranquil-pds-app tranquil-pds-nginx

Enable all services#

systemctl enable tranquil-pds-db tranquil-pds-app tranquil-pds-nginx

Configure firewall if you're into that sort of thing#

apt install -y ufw
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Now finish in the Wildcard TLS certificate section below.


Alpine with OpenRC#

Alpine uses OpenRC, not systemd, bless its soul. So instead of quadlets we use a set of OpenRC init scripts, one per container.

Install the OpenRC services#

Copy the init scripts. They run the pod, postgres, the app, and nginx as separate services ordered with depend():

cp /opt/tranquil-pds/deploy/openrc/tranquil-pds-pod /etc/init.d/
cp /opt/tranquil-pds/deploy/openrc/tranquil-pds-db /etc/init.d/
cp /opt/tranquil-pds/deploy/openrc/tranquil-pds-app /etc/init.d/
cp /opt/tranquil-pds/deploy/openrc/tranquil-pds-nginx /etc/init.d/
chmod +x /etc/init.d/tranquil-pds-pod /etc/init.d/tranquil-pds-db /etc/init.d/tranquil-pds-app /etc/init.d/tranquil-pds-nginx

The scripts default to /srv/tranquil-pds for data and /srv/tranquil-pds/config/config.toml for config. Override via /etc/conf.d/tranquil-pds-app and friends if your paths differ.

Start services#

Starting the nginx service pulls in the pod, postgres, and app through its dependencies:

rc-service tranquil-pds-nginx start

Enable services at boot time#

rc-update add tranquil-pds-pod tranquil-pds-db tranquil-pds-app tranquil-pds-nginx

Configure firewall if you're into that sort of thing#

apk add iptables ip6tables
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -P INPUT DROP
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -P INPUT DROP
rc-update add iptables
rc-update add ip6tables
/etc/init.d/iptables save
/etc/init.d/ip6tables save

Now finish in the Wildcard TLS certificate section below.


Wildcard TLS certificate#

This section sets up the real certificate for the bundled nginx.

With the stack running behind the temporary self-signed certificate, swap in a real wildcard cert. User handles are served as subdomains like nel.pds.example.com, so the certificate must cover *.pds.example.com, which requires DNS-01 validation.

Get a wildcard certificate using DNS validation:

podman run --rm -it \
  -v /srv/tranquil-pds/certs:/etc/letsencrypt:Z \
  docker.io/certbot/certbot:v5.2.2 certonly \
  --manual --preferred-challenges dns \
  -d pds.example.com -d '*.pds.example.com' \
  --agree-tos --email you@example.com

Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew. For automated renewal, use a DNS provider plugin or something.

Link the certificates into place:

ln -sf /srv/tranquil-pds/certs/live/pds.example.com/fullchain.pem /srv/tranquil-pds/certs/fullchain.pem
ln -sf /srv/tranquil-pds/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/certs/privkey.pem

Restart nginx to load them:

Debian:

systemctl restart tranquil-pds-nginx

Alpine:

rc-service tranquil-pds-nginx restart

Cert renewal#

Manual mode doesn't auto-renew, so add a renewal job to root's crontab with crontab -e.

Debian:

0 0 * * * podman run --rm -v /srv/tranquil-pds/certs:/etc/letsencrypt:Z -v /srv/tranquil-pds/acme:/var/www/acme:Z docker.io/certbot/certbot:v5.2.2 renew --quiet && systemctl reload tranquil-pds-nginx

Alpine:

0 0 * * * podman run --rm -v /srv/tranquil-pds/certs:/etc/letsencrypt -v /srv/tranquil-pds/acme:/var/www/acme docker.io/certbot/certbot:v5.2.2 renew --quiet && rc-service tranquil-pds-nginx restart

Verification and maintenance#

Verify installation#

curl -s https://pds.example.com/xrpc/_health | jq
curl -s https://pds.example.com/.well-known/atproto-did

View logs#

Debian:

journalctl -u tranquil-pds-app -f
podman logs -f tranquil-pds-app

Alpine:

rc-service tranquil-pds-app status
podman logs -f tranquil-pds-app

Update Tranquil PDS#

Pull the latest image:

podman login atcr.io
podman pull atcr.io/tranquil.farm/tranquil-pds:latest

Debian:

systemctl restart tranquil-pds-app

Alpine:

rc-service tranquil-pds-app restart

To update a source-built deployment, git pull in the repo and rebuild with podman build -t atcr.io/tranquil.farm/tranquil-pds:latest . before restarting.

Backup database#

podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql

Custom homepage#

If a homepage.html exists in the app's frontend directory it is served at /. The account dashboard stays at /app/. The directory defaults to /var/lib/tranquil-pds/frontend and is set by FRONTEND_DIR. Mount your own into the app container:

-v /srv/tranquil-pds/homepage.html:/var/lib/tranquil-pds/frontend/homepage.html:ro,Z

For ex:

<!DOCTYPE html>
<html>
<head>
    <title>Welcome to my PDS</title>
    <style>
        body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; }
    </style>
</head>
<body>
    <h1>Welcome to my dark web popsocket store</h1>
    <p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p>
    <p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p>
</body>
</html>