Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
1# Tranquil PDS containerized production deployment
2
3This guide covers deploying Tranquil PDS using containers with podman.
4
5- **Debian 13+**: Uses systemd quadlets (modern, declarative container management)
6- **Alpine 3.23+**: Uses OpenRC service script with podman-compose
7
8## Prerequisites
9
10- A server :p
11- Disk space for blobs (depends on usage; plan for ~1GB per active user as a baseline)
12- A domain name pointing to your server's IP
13- A **wildcard TLS certificate** for `*.pds.example.com` (user handles are served as subdomains)
14- Root/sudo/doas access
15
16## Quickstart (docker/podman compose)
17
18If you just want to get running quickly:
19
20```sh
21cp example.toml config.toml
22```
23
24Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
25
26Build and start:
27```sh
28podman build -t tranquil-pds:latest .
29podman build -t tranquil-pds-frontend:latest ./frontend
30podman-compose -f docker-compose.prod.yaml up -d
31```
32
33Get initial certificate (after DNS is configured):
34```sh
35podman-compose -f docker-compose.prod.yaml run --rm certbot certonly \
36 --webroot -w /var/www/acme -d pds.example.com -d '*.pds.example.com'
37ln -sf live/pds.example.com/fullchain.pem certs/fullchain.pem
38ln -sf live/pds.example.com/privkey.pem certs/privkey.pem
39podman-compose -f docker-compose.prod.yaml restart nginx
40```
41
42The end!!!
43
44Or wait, you want more? Perhaps a deployment that comes back on server restart?
45
46For production setups with proper service management, continue to either the Debian or Alpine section below.
47
48## Standalone containers (no compose)
49
50If you already have postgres running on the host (eg. from the [Debian install guide](install-debian.md)), you can run just the app containers.
51
52Build the images:
53```sh
54podman build -t tranquil-pds:latest .
55podman build -t tranquil-pds-frontend:latest ./frontend
56```
57
58Run the backend with host networking (so it can access postgres on localhost) and mount the blob storage:
59```sh
60podman run -d --name tranquil-pds \
61 --network=host \
62 -v /etc/tranquil-pds/config.toml:/etc/tranquil-pds/config.toml:ro,Z \
63 -v /var/lib/tranquil:/var/lib/tranquil:Z \
64 tranquil-pds:latest
65```
66
67Run the frontend with port mapping (the container's nginx listens on port 80):
68```sh
69podman run -d --name tranquil-pds-frontend \
70 -p 8080:80 \
71 tranquil-pds-frontend:latest
72```
73
74Then configure your host nginx to proxy to both containers. Replace the static file `try_files` directives with proxy passes:
75
76```nginx
77# API routes to backend
78location /xrpc/ {
79 proxy_pass http://127.0.0.1:3000;
80 # ... (see Debian guide for full proxy headers)
81}
82
83# Static routes to frontend container
84location / {
85 proxy_pass http://127.0.0.1:8080;
86 proxy_http_version 1.1;
87 proxy_set_header Host $host;
88 proxy_set_header X-Real-IP $remote_addr;
89 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
90 proxy_set_header X-Forwarded-Proto $scheme;
91}
92```
93
94See the [Debian install guide](install-debian.md) for the full nginx config with all API routes.
95
96---
97
98# Debian with systemd quadlets
99
100Quadlets are a nice way to run podman containers under systemd.
101
102## Install podman
103
104```bash
105apt update
106apt install -y podman
107```
108
109## Create the directory structure
110
111```bash
112mkdir -p /etc/containers/systemd
113mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config}
114```
115
116## Create a configuration file
117
118```bash
119cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
120chmod 600 /srv/tranquil-pds/config/config.toml
121```
122
123Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with:
124```bash
125openssl rand -base64 48
126```
127
128> **Note:** Every config option can also be set via environment variables
129> (see comments in `example.toml`). Environment variables always take
130> precedence over the config file.
131
132## Install quadlet definitions
133
134Copy the quadlet files from the repository:
135```bash
136cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds.pod /etc/containers/systemd/
137cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-db.container /etc/containers/systemd/
138cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-app.container /etc/containers/systemd/
139cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-frontend.container /etc/containers/systemd/
140cp /opt/tranquil-pds/deploy/quadlets/tranquil-pds-nginx.container /etc/containers/systemd/
141```
142
143Optional quadlets for valkey and minio are also available in `deploy/quadlets/` if you need them.
144
145## Create nginx configuration
146
147```bash
148cp /opt/tranquil-pds/nginx.conf /srv/tranquil-pds/config/nginx.conf
149```
150
151## Clone and build images
152
153```bash
154cd /opt
155git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
156cd tranquil-pds
157podman build -t tranquil-pds:latest .
158podman build -t tranquil-pds-frontend:latest ./frontend
159```
160
161## Create podman secrets
162
163```bash
164echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password -
165```
166
167## Start services and initialize
168
169```bash
170systemctl daemon-reload
171systemctl start tranquil-pds-db
172sleep 10
173```
174
175## Obtain a wildcard SSL cert
176
177User handles are served as subdomains (eg. `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
178
179Create temporary self-signed cert to start services:
180```bash
181openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
182 -keyout /srv/tranquil-pds/certs/privkey.pem \
183 -out /srv/tranquil-pds/certs/fullchain.pem \
184 -subj "/CN=pds.example.com"
185systemctl start tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
186```
187
188Get a wildcard certificate using DNS validation:
189```bash
190podman run --rm -it \
191 -v /srv/tranquil-pds/certs:/etc/letsencrypt:Z \
192 docker.io/certbot/certbot:v5.2.2 certonly \
193 --manual --preferred-challenges dns \
194 -d pds.example.com -d '*.pds.example.com' \
195 --agree-tos --email you@example.com
196```
197
198Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew.
199
200For automated renewal, use a DNS provider plugin (eg. cloudflare, route53).
201
202Link certificates and restart:
203```bash
204ln -sf /srv/tranquil-pds/certs/live/pds.example.com/fullchain.pem /srv/tranquil-pds/certs/fullchain.pem
205ln -sf /srv/tranquil-pds/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/certs/privkey.pem
206systemctl restart tranquil-pds-nginx
207```
208
209## Enable all services
210
211```bash
212systemctl enable tranquil-pds-db tranquil-pds-app tranquil-pds-frontend tranquil-pds-nginx
213```
214
215## Configure firewall if you're into that sort of thing
216
217```bash
218apt install -y ufw
219ufw allow ssh
220ufw allow 80/tcp
221ufw allow 443/tcp
222ufw enable
223```
224
225## Cert renewal
226
227Add to root's crontab (`crontab -e`):
228```
2290 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
230```
231
232---
233
234# Alpine with OpenRC
235
236Alpine uses OpenRC, not systemd. So instead of quadlets we'll use podman-compose with an OpenRC service wrapper.
237
238## Install podman
239
240```sh
241apk update
242apk add podman podman-compose fuse-overlayfs cni-plugins
243rc-update add cgroups
244rc-service cgroups start
245```
246
247Enable podman socket for compose:
248```sh
249rc-update add podman
250rc-service podman start
251```
252
253## Create the directory structure
254
255```sh
256mkdir -p /srv/tranquil-pds/{data,config}
257mkdir -p /srv/tranquil-pds/data/{postgres,blobs,backups,certs,acme}
258```
259
260## Clone the repo and build images
261
262```sh
263cd /opt
264git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
265cd tranquil-pds
266podman build -t tranquil-pds:latest .
267podman build -t tranquil-pds-frontend:latest ./frontend
268```
269
270## Create a configuration file
271
272```sh
273cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml
274chmod 600 /srv/tranquil-pds/config/config.toml
275```
276
277Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with:
278```sh
279openssl rand -base64 48
280```
281
282> **Note:** Every config option can also be set via environment variables
283> (see comments in `example.toml`). Environment variables always take
284> precedence over the config file.
285
286## Set up compose and nginx
287
288Copy the production compose and nginx configs:
289```sh
290cp /opt/tranquil-pds/docker-compose.prod.yaml /srv/tranquil-pds/docker-compose.yml
291cp /opt/tranquil-pds/nginx.conf /srv/tranquil-pds/config/nginx.conf
292```
293
294Edit `/srv/tranquil-pds/docker-compose.yml` to adjust paths if needed:
295- Update volume mounts to use `/srv/tranquil-pds/data/` paths
296- Update nginx config path to `/srv/tranquil-pds/config/nginx.conf`
297
298Edit `/srv/tranquil-pds/config/nginx.conf` to update cert paths:
299- Change `/etc/nginx/certs/live/${PDS_HOSTNAME}/` to `/etc/nginx/certs/`
300
301## Create OpenRC service
302
303```sh
304cat > /etc/init.d/tranquil-pds << 'EOF'
305#!/sbin/openrc-run
306name="tranquil-pds"
307description="Tranquil PDS AT Protocol PDS"
308command="/usr/bin/podman-compose"
309command_args="-f /srv/tranquil-pds/docker-compose.yml up"
310command_background=true
311pidfile="/run/${RC_SVCNAME}.pid"
312directory="/srv/tranquil-pds"
313depend() {
314 need net podman
315 after firewall
316}
317start_pre() {
318 checkpath -d /srv/tranquil-pds
319}
320stop() {
321 ebegin "Stopping ${name}"
322 cd /srv/tranquil-pds
323 podman-compose -f /srv/tranquil-pds/docker-compose.yml down
324 eend $?
325}
326EOF
327chmod +x /etc/init.d/tranquil-pds
328```
329
330## Initialize services
331
332Start services:
333```sh
334rc-service tranquil-pds start
335sleep 15
336```
337
338Run migrations:
339```sh
340apk add rustup
341rustup-init -y
342source ~/.cargo/env
343cargo install sqlx-cli --no-default-features --features postgres
344DB_IP=$(podman inspect tranquil-pds-db-1 --format '{{.NetworkSettings.Networks.tranquil-pds_default.IPAddress}}')
345DATABASE_URL="postgres://tranquil_pds:$DB_PASSWORD@$DB_IP:5432/pds" sqlx migrate run --source /opt/tranquil-pds/migrations
346```
347
348## Obtain wildcard SSL cert
349
350User handles are served as subdomains (eg. `alice.pds.example.com`), so you need a wildcard certificate. Wildcard certs require DNS-01 validation.
351
352Create temporary self-signed cert to start services:
353```sh
354openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
355 -keyout /srv/tranquil-pds/data/certs/privkey.pem \
356 -out /srv/tranquil-pds/data/certs/fullchain.pem \
357 -subj "/CN=pds.example.com"
358rc-service tranquil-pds restart
359```
360
361Get a wildcard certificate using DNS validation:
362```sh
363podman run --rm -it \
364 -v /srv/tranquil-pds/data/certs:/etc/letsencrypt \
365 docker.io/certbot/certbot:v5.2.2 certonly \
366 --manual --preferred-challenges dns \
367 -d pds.example.com -d '*.pds.example.com' \
368 --agree-tos --email you@example.com
369```
370
371Follow the prompts to add TXT records to your DNS. Note: manual mode doesn't auto-renew.
372
373Link certificates and restart:
374```sh
375ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/fullchain.pem /srv/tranquil-pds/data/certs/fullchain.pem
376ln -sf /srv/tranquil-pds/data/certs/live/pds.example.com/privkey.pem /srv/tranquil-pds/data/certs/privkey.pem
377rc-service tranquil-pds restart
378```
379
380## Enable service at boot time
381
382```sh
383rc-update add tranquil-pds
384```
385
386## Configure firewall if you're into that sort of thing
387
388```sh
389apk add iptables ip6tables
390iptables -A INPUT -p tcp --dport 22 -j ACCEPT
391iptables -A INPUT -p tcp --dport 80 -j ACCEPT
392iptables -A INPUT -p tcp --dport 443 -j ACCEPT
393iptables -A INPUT -i lo -j ACCEPT
394iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
395iptables -P INPUT DROP
396ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
397ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
398ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
399ip6tables -A INPUT -i lo -j ACCEPT
400ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
401ip6tables -P INPUT DROP
402rc-update add iptables
403rc-update add ip6tables
404/etc/init.d/iptables save
405/etc/init.d/ip6tables save
406```
407
408## Cert renewal
409
410Add to root's crontab (`crontab -e`):
411```
4120 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
413```
414
415---
416
417# Verification and maintenance
418
419## Verify installation
420
421```sh
422curl -s https://pds.example.com/xrpc/_health | jq
423curl -s https://pds.example.com/.well-known/atproto-did
424```
425
426## View logs
427
428**Debian:**
429```bash
430journalctl -u tranquil-pds-app -f
431podman logs -f tranquil-pds-app
432podman logs -f tranquil-pds-frontend
433```
434
435**Alpine:**
436```sh
437podman-compose -f /srv/tranquil-pds/docker-compose.yml logs -f
438podman logs -f tranquil-pds-tranquil-pds-1
439podman logs -f tranquil-pds-frontend-1
440```
441
442## Update Tranquil PDS
443
444```sh
445cd /opt/tranquil-pds
446git pull
447podman build -t tranquil-pds:latest .
448podman build -t tranquil-pds-frontend:latest ./frontend
449```
450
451Debian:
452```bash
453systemctl restart tranquil-pds-app tranquil-pds-frontend
454```
455
456Alpine:
457```sh
458rc-service tranquil-pds restart
459```
460
461## Backup database
462
463**Debian:**
464```bash
465podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
466```
467
468**Alpine:**
469```sh
470podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
471```
472
473## Custom homepage
474
475The frontend container serves `homepage.html` as the landing page. To customize it, either:
476
4771. Build a custom frontend image with your own `homepage.html`
4782. Mount a custom `homepage.html` into the frontend container
479
480Example custom homepage:
481```html
482<!DOCTYPE html>
483<html>
484<head>
485 <title>Welcome to my PDS</title>
486 <style>
487 body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; }
488 </style>
489</head>
490<body>
491 <h1>Welcome to my dark web popsocket store</h1>
492 <p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p>
493 <p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p>
494</body>
495</html>
496```