Hi!

I run my own ATProto PDS, for various reasons:

  • I want to own my data.

  • It's a good way to make the ATProto network resilient against centralized infrastructure.

  • I'm a nerd.

Last year I set myself a challenge: buy the cheapest VPS during the Black Friday deals and host my PDS on it.

found a good deal on a black friday VPS in the lowendbox forums 10 euros, 1.5gb ram, 25gb hdd, 1 core

It hasn't been a flawless experience - mostly due to deliberately choosing cheap hosting - but the process gave me the knowledge and confidence to host my main ATProto account on it: this post explains how I did it.

PDS hosting doesn't require many resources: posts are lightweight, the only media you're storing is your own, and the nature of the relay architecture means you have limited traffic aimed directly at your server - except if you're hosting your own website on it!

As a huge Alpine Linux fan, I installed it through the VPS administration panel, and as expected, it has performed flawlessly.

The entire stack is executed on rootless Podman, managed remotely through Portainer: I use a variation of the official Bluesky compose.yml file that I extracted their repository.

I don't like the install.sh approach as it feels brittle and hard to maintain long-term: I run many services on my machines, all of them containerized, to avoid messing with the host configuration files and runtime.

I kept Caddy as my reverse proxy of choice because it's set-and-forget once configured properly, and the setup Bluesky provides just works.

Bluesky suggests the use of containrrr/watchtower, but I'd advise against it: I've been bitten in the past by indiscriminate upgrades, mostly due to pulling Docker images with the latest tag, and I'd like not to repeat that experience anymore.

Instead of automated updates, I subscribed to Bluesky's Github notifications to keep myself up to date with new releases.

My philosophy for this deployment is to do the best you can: posting on Bluesky is not a vital service for me, I'm okay with a few hours of downtime.

So far the PDS software seems to be managed in a sane way, following semver best practices.

I never had issues stemming from docker pulling every once in a while.

The only problem I encountered was due to the host system clock losing track of time, which I promptly solved by enabling an NTP daemon.

To mitigate the risk of data loss, I backup the data directory on Borgbase with Restic every few hours - I also schedule regular scrubs and checks with the same Docker image.

Without further ado, here's the compose.yml file:

version: "3.9"
services:
  pds:
    container_name: pds
    image: ghcr.io/bluesky-social/pds:0.4
    restart: unless-stopped
    env_file: "./stack.env"
    volumes:
      - type: bind
        source: ${DATADIR}
        target: /pds
  caddy:
    image: caddy:latest
    restart: unless-stopped
    command: caddy run --config /etc/caddy/Caddyfile
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ${DATADIR}/geesawra.industries:/geesawra.industries
      - ${DATADIR}/caddy/Caddyfile:/etc/caddy/Caddyfile
      - ${DATADIR}/caddy/data:/data
      - ${DATADIR}/caddy/config:/config

  backup:
    image: mazzolino/restic
    hostname: docker
    restart: unless-stopped
    environment:
      RUN_ON_STARTUP: "true"
      BACKUP_CRON: "*/45 * * * *"
      RESTIC_CHECK_ARGS: >-
        --read-data-subset=10%
      RESTIC_REPOSITORY: rest:[REDACTED]
      RESTIC_PASSWORD: [REDACTED]
      RESTIC_BACKUP_SOURCES: /mnt/pds
      RESTIC_BACKUP_ARGS: >-
        --tag pds
      RESTIC_FORGET_ARGS: >-
        --keep-last 10
        --keep-daily 7
        --keep-weekly 5
        --keep-monthly 12
      TZ: Europe/Berlin
    volumes:
      - ${DATADIR}:/mnt/pds:ro

  prune:
    image: mazzolino/restic
    hostname: docker
    restart: unless-stopped
    environment:
      SKIP_INIT: "true"
      RUN_ON_STARTUP: "false"
      PRUNE_CRON: "0 0 4 * * *"
      RESTIC_REPOSITORY: rest:[REDACTED]
      RESTIC_PASSWORD: [REDACTED]
      TZ: Europe/Berlin

  check:
    image: mazzolino/restic
    hostname: docker
    restart: unless-stopped
    environment:
      SKIP_INIT: "true"
      RUN_ON_STARTUP: "false"
      CHECK_CRON: "0 15 5 * * *"
      RESTIC_CHECK_ARGS: >-
        --read-data-subset=10%
      RESTIC_REPOSITORY: rest:[REDACTED]
      RESTIC_PASSWORD: [REDACTED]
      TZ: Europe/Berlin

There are two notable features in this compose.yml:

  • As pds is configured through environment variables, I'm storing them in a stack.env - this file is auto-generated by Portainer, managed through its web UI.

  • I'm binding the host volume path to ${DATADIR}, which is defined in stack.env.

Running a PDS isn't exactly a walk in the park, but if you've played around with containers before and feel at home in the Linux shell, you should be good to go.

On a positive note, migrating my account from bsky.social to my own PDS has been a great experience, and it's certainly something the ActivityPub folks should look into for their next protocol iteration.

migration done with goat, had to generate an invite code manually (aka create-invite-code.sh in the pds repo) but besides that the entire process took a few minutes and worked perfectly thank you @bsky.app very cool

the fact that it migrated all my posts, media, following, followers, likes and all that stuff automatically is kind of mind-blowing i did a mastodon migration and it sucked

Until next time!