Tranquil PDS containerized production deployment#
This guide covers deploying Tranquil PDS using containers with podman.
- Debian 13+: Uses systemd quadlets (modern, declarative container management)
- Alpine 3.23+: Uses OpenRC service script with podman-compose
Prerequisites#
- A server :p
- Disk space for blobs (depends on usage; plan for ~1GB per active user as a baseline)
- A domain name pointing to your server's IP
- A wildcard TLS certificate for
*.pds.example.com(user handles are served as subdomains) - Root/sudo/doas access
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.
Build and start:
podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
podman-compose -f docker-compose.prod.yaml up -d
Get initial certificate (after DNS is configured):
podman-compose -f docker-compose.prod.yaml run --rm certbot certonly \
--webroot -w /var/www/acme -d pds.example.com -d '*.pds.example.com'
ln -sf live/pds.example.com/fullchain.pem certs/fullchain.pem
ln -sf live/pds.example.com/privkey.pem certs/privkey.pem
podman-compose -f docker-compose.prod.yaml restart nginx
The end!!!
Or wait, you want more? Perhaps a deployment that comes back on server restart?
For production setups with proper service management, continue to either the Debian or Alpine section below.
Standalone containers (no compose)#
If you already have postgres running on the host (eg. from the Debian install guide), you can run just the app containers.
Build the images:
podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
Run the backend with host networking (so it can access postgres on localhost) and mount the blob 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:/var/lib/tranquil:Z \
tranquil-pds:latest
Run the frontend with port mapping (the container's nginx listens on port 80):
podman run -d --name tranquil-pds-frontend \
-p 8080:80 \
tranquil-pds-frontend:latest
Then configure your host nginx to proxy to both containers. Replace the static file try_files directives with proxy passes:
# API routes to backend
location /xrpc/ {
proxy_pass http://127.0.0.1:3000;
# ... (see Debian guide for full proxy headers)
}
# Static routes to frontend container
location / {
proxy_pass http://127.0.0.1:8080;
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;
}
See the Debian install guide for the full nginx config with all API routes.
Debian with systemd quadlets#
Quadlets are a nice way to run podman containers under systemd.
Install podman#
apt update
apt install -y podman
Create the directory structure#
mkdir -p /etc/containers/systemd
mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config}
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
Note: Every config option can also be set via environment variables (see comments in
example.toml). Environment variables always take precedence over the config file.
Install quadlet definitions#
Copy the quadlet files from the repository:
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-frontend.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.
Create nginx configuration#
cp /opt/tranquil-pds/nginx.conf /srv/tranquil-pds/config/nginx.conf
Clone and build images#
cd /opt
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
cd tranquil-pds
podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
Create podman secrets#
echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
Start services and initialize#
systemctl daemon-reload
systemctl start tranquil-pds-db
sleep 10
Obtain a wildcard SSL cert#
User handles are served as subdomains (eg. alice.pds.example.com), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
Create temporary self-signed cert to start services:
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"
systemctl start tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
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 (eg. cloudflare, route53).
Link certificates and restart:
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
systemctl restart tranquil-pds-nginx
Enable all services#
systemctl enable tranquil-pds-db tranquil-pds-app tranquil-pds-frontend 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
Cert renewal#
Add to root's crontab (crontab -e):
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 with OpenRC#
Alpine uses OpenRC, not systemd. So instead of quadlets we'll use podman-compose with an OpenRC service wrapper.
Install podman#
apk update
apk add podman podman-compose fuse-overlayfs cni-plugins
rc-update add cgroups
rc-service cgroups start
Enable podman socket for compose:
rc-update add podman
rc-service podman start
Create the directory structure#
mkdir -p /srv/tranquil-pds/{data,config}
mkdir -p /srv/tranquil-pds/data/{postgres,blobs,backups,certs,acme}
Clone the repo and build images#
cd /opt
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
cd tranquil-pds
podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
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
Note: Every config option can also be set via environment variables (see comments in
example.toml). Environment variables always take precedence over the config file.
Set up compose and nginx#
Copy the production compose and nginx configs:
cp /opt/tranquil-pds/docker-compose.prod.yaml /srv/tranquil-pds/docker-compose.yml
cp /opt/tranquil-pds/nginx.conf /srv/tranquil-pds/config/nginx.conf
Edit /srv/tranquil-pds/docker-compose.yml to adjust paths if needed:
- Update volume mounts to use
/srv/tranquil-pds/data/paths - Update nginx config path to
/srv/tranquil-pds/config/nginx.conf
Edit /srv/tranquil-pds/config/nginx.conf to update cert paths:
- Change
/etc/nginx/certs/live/${PDS_HOSTNAME}/to/etc/nginx/certs/
Create OpenRC service#
cat > /etc/init.d/tranquil-pds << 'EOF'
#!/sbin/openrc-run
name="tranquil-pds"
description="Tranquil PDS AT Protocol PDS"
command="/usr/bin/podman-compose"
command_args="-f /srv/tranquil-pds/docker-compose.yml up"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
directory="/srv/tranquil-pds"
depend() {
need net podman
after firewall
}
start_pre() {
checkpath -d /srv/tranquil-pds
}
stop() {
ebegin "Stopping ${name}"
cd /srv/tranquil-pds
podman-compose -f /srv/tranquil-pds/docker-compose.yml down
eend $?
}
EOF
chmod +x /etc/init.d/tranquil-pds
Initialize services#
Start services:
rc-service tranquil-pds start
sleep 15
Run migrations:
apk add rustup
rustup-init -y
source ~/.cargo/env
cargo install sqlx-cli --no-default-features --features postgres
DB_IP=$(podman inspect tranquil-pds-db-1 --format '{{.NetworkSettings.Networks.tranquil-pds_default.IPAddress}}')
DATABASE_URL="postgres://tranquil_pds:$DB_PASSWORD@$DB_IP:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
Obtain wildcard SSL cert#
User handles are served as subdomains (eg. alice.pds.example.com), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
Create temporary self-signed cert to start services:
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout /srv/tranquil-pds/data/certs/privkey.pem \
-out /srv/tranquil-pds/data/certs/fullchain.pem \
-subj "/CN=pds.example.com"
rc-service tranquil-pds restart
Get a wildcard certificate using DNS validation:
podman run --rm -it \
-v /srv/tranquil-pds/data/certs:/etc/letsencrypt \
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.
Link certificates and restart:
ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/fullchain.pem /srv/tranquil-pds/data/certs/fullchain.pem
ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/data/certs/privkey.pem
rc-service tranquil-pds restart
Enable service at boot time#
rc-update add tranquil-pds
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
Cert renewal#
Add to root's crontab (crontab -e):
0 0 * * * podman run --rm -v /srv/tranquil-pds/data/certs:/etc/letsencrypt -v /srv/tranquil-pds/data/acme:/var/www/acme docker.io/certbot/certbot:v5.2.2 renew --quiet && rc-service tranquil-pds 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
podman logs -f tranquil-pds-frontend
Alpine:
podman-compose -f /srv/tranquil-pds/docker-compose.yml logs -f
podman logs -f tranquil-pds-tranquil-pds-1
podman logs -f tranquil-pds-frontend-1
Update Tranquil PDS#
cd /opt/tranquil-pds
git pull
podman build -t tranquil-pds:latest .
podman build -t tranquil-pds-frontend:latest ./frontend
Debian:
systemctl restart tranquil-pds-app tranquil-pds-frontend
Alpine:
rc-service tranquil-pds restart
Backup database#
Debian:
podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
Alpine:
podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
Custom homepage#
The frontend container serves homepage.html as the landing page. To customize it, either:
- Build a custom frontend image with your own
homepage.html - Mount a custom
homepage.htmlinto the frontend container
Example custom homepage:
<!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>