Fork of official Bluesky PDS (Personal Data Server).
1#!/usr/bin/env bash
2set -o errexit
3set -o nounset
4set -o pipefail
5
6# Disable prompts for apt-get.
7export DEBIAN_FRONTEND="noninteractive"
8
9# System info.
10PLATFORM="$(uname --hardware-platform || true)"
11DISTRIB_CODENAME="$(lsb_release --codename --short || true)"
12DISTRIB_ID="$(lsb_release --id --short | tr '[:upper:]' '[:lower:]' || true)"
13
14# Secure generator commands
15GENERATE_SECURE_SECRET_CMD="openssl rand --hex 16"
16GENERATE_K256_PRIVATE_KEY_CMD="openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32"
17
18# The Docker compose file.
19COMPOSE_URL="https://raw.githubusercontent.com/bluesky-social/pds/main/compose.yaml"
20
21# The pdsadmin script.
22PDSADMIN_URL="https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin.sh"
23
24# System dependencies.
25REQUIRED_SYSTEM_PACKAGES="
26 ca-certificates
27 curl
28 gnupg
29 jq
30 lsb-release
31 openssl
32 sqlite3
33 xxd
34"
35# Docker packages.
36REQUIRED_DOCKER_PACKAGES="
37 containerd.io
38 docker-ce
39 docker-ce-cli
40 docker-compose-plugin
41"
42
43PUBLIC_IP=""
44METADATA_URLS=()
45METADATA_URLS+=("http://169.254.169.254/v1/interfaces/0/ipv4/address") # Vultr
46METADATA_URLS+=("http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address") # DigitalOcean
47METADATA_URLS+=("http://169.254.169.254/2021-03-23/meta-data/public-ipv4") # AWS
48METADATA_URLS+=("http://169.254.169.254/hetzner/v1/metadata/public-ipv4") # Hetzner
49
50PDS_DATADIR="${1:-/pds}"
51PDS_HOSTNAME="${2:-}"
52PDS_ADMIN_EMAIL="${3:-}"
53PDS_DID_PLC_URL="https://plc.directory"
54PDS_BSKY_APP_VIEW_URL="https://api.bsky.app"
55PDS_BSKY_APP_VIEW_DID="did:web:api.bsky.app"
56PDS_REPORT_SERVICE_URL="https://mod.bsky.app"
57PDS_REPORT_SERVICE_DID="did:plc:ar7c4by46qjdydhdevvrndac"
58PDS_CRAWLERS="https://bsky.network"
59
60function usage {
61 local error="${1}"
62 cat <<USAGE >&2
63ERROR: ${error}
64Usage:
65sudo bash $0
66
67Please try again.
68USAGE
69 exit 1
70}
71
72function main {
73 # Check that user is root.
74 if [[ "${EUID}" -ne 0 ]]; then
75 usage "This script must be run as root. (e.g. sudo $0)"
76 fi
77
78 # Check for a supported architecture.
79 # If the platform is unknown (not uncommon) then we assume x86_64
80 if [[ "${PLATFORM}" == "unknown" ]]; then
81 PLATFORM="x86_64"
82 fi
83 if [[ "${PLATFORM}" != "x86_64" ]] && [[ "${PLATFORM}" != "aarch64" ]] && [[ "${PLATFORM}" != "arm64" ]]; then
84 usage "Sorry, only x86_64 and aarch64/arm64 are supported. Exiting..."
85 fi
86
87 # Check for a supported distribution.
88 SUPPORTED_OS="false"
89 if [[ "${DISTRIB_ID}" == "ubuntu" ]]; then
90 if [[ "${DISTRIB_CODENAME}" == "focal" ]]; then
91 SUPPORTED_OS="true"
92 echo "* Detected supported distribution Ubuntu 20.04 LTS"
93 elif [[ "${DISTRIB_CODENAME}" == "jammy" ]]; then
94 SUPPORTED_OS="true"
95 echo "* Detected supported distribution Ubuntu 22.04 LTS"
96 elif [[ "${DISTRIB_CODENAME}" == "noble" ]]; then
97 SUPPORTED_OS="true"
98 echo "* Detected supported distribution Ubuntu 24.04 LTS"
99 fi
100 elif [[ "${DISTRIB_ID}" == "debian" ]]; then
101 if [[ "${DISTRIB_CODENAME}" == "bullseye" ]]; then
102 SUPPORTED_OS="true"
103 echo "* Detected supported distribution Debian 11"
104 elif [[ "${DISTRIB_CODENAME}" == "bookworm" ]]; then
105 SUPPORTED_OS="true"
106 echo "* Detected supported distribution Debian 12"
107 elif [[ "${DISTRIB_CODENAME}" == "trixie" ]]; then
108 SUPPORTED_OS="true"
109 echo "* Detected supported distribution Debian 13"
110 fi
111 fi
112
113 if [[ "${SUPPORTED_OS}" != "true" ]]; then
114 echo "Sorry, only Ubuntu 20.04, 22.04, 24.04, and Debian 11, 12, and 13 are supported by this installer. Exiting..."
115 exit 1
116 fi
117
118 # Enforce that the data directory is /pds since we're assuming it for now.
119 # Later we can make this actually configurable.
120 if [[ "${PDS_DATADIR}" != "/pds" ]]; then
121 usage "The data directory must be /pds. Exiting..."
122 fi
123
124 # Check if PDS is already installed.
125 if [[ -e "${PDS_DATADIR}/account.sqlite" ]]; then
126 echo
127 echo "ERROR: pds is already configured in ${PDS_DATADIR}"
128 echo
129 echo "To do a clean re-install:"
130 echo "------------------------------------"
131 echo "1. Stop the service"
132 echo
133 echo " sudo systemctl stop pds"
134 echo
135 echo "2. Delete the data directory"
136 echo
137 echo " sudo rm -rf ${PDS_DATADIR}"
138 echo
139 echo "3. Re-run this installation script"
140 echo
141 echo " sudo bash ${0}"
142 echo
143 echo "For assistance, check https://github.com/bluesky-social/pds"
144 exit 1
145 fi
146
147 #
148 # Attempt to determine server's public IP.
149 #
150
151 # First try using the hostname command, which usually works.
152 if [[ -z "${PUBLIC_IP}" ]]; then
153 PUBLIC_IP=$(hostname --all-ip-addresses | awk '{ print $1 }')
154 fi
155
156 # Prevent any private IP address from being used, since it won't work.
157 if [[ "${PUBLIC_IP}" =~ ^(127\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.|192\.168\.) ]]; then
158 PUBLIC_IP=""
159 fi
160
161 # Check the various metadata URLs.
162 if [[ -z "${PUBLIC_IP}" ]]; then
163 for METADATA_URL in "${METADATA_URLS[@]}"; do
164 METADATA_IP="$(timeout 2 curl --silent --show-error "${METADATA_URL}" | head --lines=1 || true)"
165 if [[ "${METADATA_IP}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
166 PUBLIC_IP="${METADATA_IP}"
167 break
168 fi
169 done
170 fi
171
172 if [[ -z "${PUBLIC_IP}" ]]; then
173 PUBLIC_IP="Server's IP"
174 fi
175
176 #
177 # Prompt user for required variables.
178 #
179 if [[ -z "${PDS_HOSTNAME}" ]]; then
180 cat <<INSTALLER_MESSAGE
181---------------------------------------
182 Add DNS Record for Public IP
183---------------------------------------
184
185 From your DNS provider's control panel, create the required
186 DNS record with the value of your server's public IP address.
187
188 + Any DNS name that can be resolved on the public internet will work.
189 + Replace example.com below with any valid domain name you control.
190 + A TTL of 600 seconds (10 minutes) is recommended.
191
192 Example DNS record:
193
194 NAME TYPE VALUE
195 ---- ---- -----
196 example.com A ${PUBLIC_IP:-Server public IP}
197 *.example.com A ${PUBLIC_IP:-Server public IP}
198
199 **IMPORTANT**
200 It's recommended to wait 3-5 minutes after creating a new DNS record
201 before attempting to use it. This will allow time for the DNS record
202 to be fully updated.
203
204INSTALLER_MESSAGE
205
206 if [[ -z "${PDS_HOSTNAME}" ]]; then
207 read -p "Enter your public DNS address (e.g. example.com): " PDS_HOSTNAME
208 fi
209 fi
210
211 if [[ -z "${PDS_HOSTNAME}" ]]; then
212 usage "No public DNS address specified"
213 fi
214
215 if [[ "${PDS_HOSTNAME}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
216 usage "Invalid public DNS address (must not be an IP address)"
217 fi
218
219 # Admin email
220 if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
221 read -p "Enter an admin email address (e.g. you@example.com): " PDS_ADMIN_EMAIL
222 fi
223 if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
224 usage "No admin email specified"
225 fi
226
227 #
228 # Install system packages.
229 #
230 if lsof -v >/dev/null 2>&1; then
231 while true; do
232 apt_process_count="$(lsof -n -t /var/cache/apt/archives/lock /var/lib/apt/lists/lock /var/lib/dpkg/lock | wc --lines || true)"
233 if (( apt_process_count == 0 )); then
234 break
235 fi
236 echo "* Waiting for other apt process to complete..."
237 sleep 2
238 done
239 fi
240
241 apt-get update
242 apt-get install --yes ${REQUIRED_SYSTEM_PACKAGES}
243
244 #
245 # Install Docker
246 #
247 if ! docker version >/dev/null 2>&1; then
248 echo "* Installing Docker"
249 mkdir --parents /etc/apt/keyrings
250
251 # Remove the existing file, if it exists,
252 # so there's no prompt on a second run.
253 rm --force /etc/apt/keyrings/docker.gpg
254 curl --fail --silent --show-error --location "https://download.docker.com/linux/${DISTRIB_ID}/gpg" | \
255 gpg --dearmor --output /etc/apt/keyrings/docker.gpg
256
257 echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DISTRIB_ID} ${DISTRIB_CODENAME} stable" >/etc/apt/sources.list.d/docker.list
258
259 apt-get update
260 apt-get install --yes ${REQUIRED_DOCKER_PACKAGES}
261 fi
262
263 #
264 # Configure the Docker daemon so that logs don't fill up the disk.
265 #
266 if ! [[ -e /etc/docker/daemon.json ]]; then
267 echo "* Configuring Docker daemon"
268 cat <<'DOCKERD_CONFIG' >/etc/docker/daemon.json
269{
270 "log-driver": "json-file",
271 "log-opts": {
272 "max-size": "500m",
273 "max-file": "4"
274 }
275}
276DOCKERD_CONFIG
277 systemctl restart docker
278 else
279 echo "* Docker daemon already configured! Ensure log rotation is enabled."
280 fi
281
282 #
283 # Create data directory.
284 #
285 if ! [[ -d "${PDS_DATADIR}" ]]; then
286 echo "* Creating data directory ${PDS_DATADIR}"
287 mkdir --parents "${PDS_DATADIR}"
288 fi
289 chmod 700 "${PDS_DATADIR}"
290
291 #
292 # Configure Caddy
293 #
294 if ! [[ -d "${PDS_DATADIR}/caddy/data" ]]; then
295 echo "* Creating Caddy data directory"
296 mkdir --parents "${PDS_DATADIR}/caddy/data"
297 fi
298 if ! [[ -d "${PDS_DATADIR}/caddy/etc/caddy" ]]; then
299 echo "* Creating Caddy config directory"
300 mkdir --parents "${PDS_DATADIR}/caddy/etc/caddy"
301 fi
302
303 echo "* Creating Caddy config file"
304 cat <<CADDYFILE >"${PDS_DATADIR}/caddy/etc/caddy/Caddyfile"
305{
306 email ${PDS_ADMIN_EMAIL}
307 on_demand_tls {
308 ask http://localhost:3000/tls-check
309 }
310}
311
312*.${PDS_HOSTNAME}, ${PDS_HOSTNAME} {
313 tls {
314 on_demand
315 }
316 reverse_proxy http://localhost:3000
317}
318CADDYFILE
319
320 #
321 # Create the PDS env config
322 #
323 # Created here so that we can use it later in multiple places.
324 PDS_ADMIN_PASSWORD=$(eval "${GENERATE_SECURE_SECRET_CMD}")
325 cat <<PDS_CONFIG >"${PDS_DATADIR}/pds.env"
326PDS_HOSTNAME=${PDS_HOSTNAME}
327PDS_JWT_SECRET=$(eval "${GENERATE_SECURE_SECRET_CMD}")
328PDS_ADMIN_PASSWORD=${PDS_ADMIN_PASSWORD}
329PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(eval "${GENERATE_K256_PRIVATE_KEY_CMD}")
330PDS_DATA_DIRECTORY=${PDS_DATADIR}
331PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATADIR}/blocks
332PDS_BLOB_UPLOAD_LIMIT=104857600
333PDS_DID_PLC_URL=${PDS_DID_PLC_URL}
334PDS_BSKY_APP_VIEW_URL=${PDS_BSKY_APP_VIEW_URL}
335PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID}
336PDS_REPORT_SERVICE_URL=${PDS_REPORT_SERVICE_URL}
337PDS_REPORT_SERVICE_DID=${PDS_REPORT_SERVICE_DID}
338PDS_CRAWLERS=${PDS_CRAWLERS}
339LOG_ENABLED=true
340PDS_RATE_LIMITS_ENABLED=true
341PDS_CONFIG
342
343 #
344 # Download and install pds launcher.
345 #
346 echo "* Downloading PDS compose file"
347 curl \
348 --silent \
349 --show-error \
350 --fail \
351 --output "${PDS_DATADIR}/compose.yaml" \
352 "${COMPOSE_URL}"
353
354 # Replace the /pds paths with the ${PDS_DATADIR} path.
355 sed --in-place "s|/pds|${PDS_DATADIR}|g" "${PDS_DATADIR}/compose.yaml"
356
357 #
358 # Create the systemd service.
359 #
360 echo "* Starting the pds systemd service"
361 cat <<SYSTEMD_UNIT_FILE >/etc/systemd/system/pds.service
362[Unit]
363Description=Bluesky PDS Service
364Documentation=https://github.com/bluesky-social/pds
365Requires=docker.service
366After=docker.service
367
368[Service]
369Type=oneshot
370RemainAfterExit=yes
371WorkingDirectory=${PDS_DATADIR}
372ExecStart=/usr/bin/docker compose --file ${PDS_DATADIR}/compose.yaml up --detach
373ExecStop=/usr/bin/docker compose --file ${PDS_DATADIR}/compose.yaml down
374
375[Install]
376WantedBy=default.target
377SYSTEMD_UNIT_FILE
378
379 systemctl daemon-reload
380 systemctl enable pds
381 systemctl restart pds
382
383 # Enable firewall access if ufw is in use.
384 if ufw status >/dev/null 2>&1; then
385 if ! ufw status | grep --quiet '^80[/ ]'; then
386 echo "* Enabling access on TCP port 80 using ufw"
387 ufw allow 80/tcp >/dev/null
388 fi
389 if ! ufw status | grep --quiet '^443[/ ]'; then
390 echo "* Enabling access on TCP port 443 using ufw"
391 ufw allow 443/tcp >/dev/null
392 fi
393 fi
394
395 #
396 # Download and install pdsadmin.
397 #
398 echo "* Downloading pdsadmin"
399 curl \
400 --silent \
401 --show-error \
402 --fail \
403 --output "/usr/local/bin/pdsadmin" \
404 "${PDSADMIN_URL}"
405 chmod +x /usr/local/bin/pdsadmin
406
407 cat <<INSTALLER_MESSAGE
408========================================================================
409PDS installation successful!
410------------------------------------------------------------------------
411
412Check service status : sudo systemctl status pds
413Watch service logs : sudo docker logs -f pds
414Backup service data : ${PDS_DATADIR}
415PDS Admin command : pdsadmin
416
417Required Firewall Ports
418------------------------------------------------------------------------
419Service Direction Port Protocol Source
420------- --------- ---- -------- ----------------------
421HTTP TLS verification Inbound 80 TCP Any
422HTTP Control Panel Inbound 443 TCP Any
423
424Required DNS entries
425------------------------------------------------------------------------
426Name Type Value
427------- --------- ---------------
428${PDS_HOSTNAME} A ${PUBLIC_IP}
429*.${PDS_HOSTNAME} A ${PUBLIC_IP}
430
431Detected public IP of this server: ${PUBLIC_IP}
432
433To see pdsadmin commands, run "pdsadmin help"
434
435========================================================================
436INSTALLER_MESSAGE
437
438 CREATE_ACCOUNT_PROMPT=""
439 read -p "Create a PDS user account? (y/N): " CREATE_ACCOUNT_PROMPT
440
441 if [[ "${CREATE_ACCOUNT_PROMPT}" =~ ^[Yy] ]]; then
442 pdsadmin account create
443 fi
444
445}
446
447# Run main function.
448main