14 KiB
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 VPS with at least 2GB RAM
- 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 or sudo access
Quick Start (Docker/Podman Compose)
If you just want to get running quickly:
cp .env.example .env
Edit .env 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
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 \
--env-file /etc/tranquil-pds/tranquil-pds.env \
-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 13+ with Systemd Quadlets
Quadlets are the modern way to run podman containers under systemd.
Install Podman
apt update
apt install -y podman
Create Directory Structure
mkdir -p /etc/containers/systemd
mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config}
Create Environment File
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
Edit /srv/tranquil-pds/config/tranquil-pds.env and fill in your values. Generate secrets with:
openssl rand -base64 48
For quadlets, also add DATABASE_URL with the full connection string (systemd doesn't support variable expansion).
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.
Note: Systemd doesn't support shell-style variable expansion in Environment= lines. The quadlet files expect DATABASE_URL to be set in the environment file.
Create nginx Configuration
cp /opt/tranquil-pds/nginx.frontend.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
source /srv/tranquil-pds/config/tranquil-pds.env
echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
Start Services and Initialize
systemctl daemon-reload
systemctl start tranquil-pds-db
sleep 10
Run migrations:
cargo install sqlx-cli --no-default-features --features postgres
DATABASE_URL="postgres://tranquil_pds:your-db-password@localhost:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
Obtain Wildcard SSL Certificate
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
apt install -y ufw
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Certificate 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 3.23+ with OpenRC
Alpine uses OpenRC, not systemd. 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 Directory Structure
mkdir -p /srv/tranquil-pds/{data,config}
mkdir -p /srv/tranquil-pds/data/{postgres,blobs,backups,certs,acme}
Clone Repository 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 Environment File
cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env
chmod 600 /srv/tranquil-pds/config/tranquil-pds.env
Edit /srv/tranquil-pds/config/tranquil-pds.env and fill in your values. Generate secrets with:
openssl rand -base64 48
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.frontend.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 (containerized)"
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() {
set -a
. /srv/tranquil-pds/config/tranquil-pds.env
set +a
}
stop() {
ebegin "Stopping ${name}"
cd /srv/tranquil-pds
set -a
. /srv/tranquil-pds/config/tranquil-pds.env
set +a
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 Certificate
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
rc-update add tranquil-pds
Configure Firewall
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
Certificate 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>