{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreihnd62w7mnancj66mkgnadaqnopwp6q7hfoikqzcojmxrkari6k6q",
    "uri": "at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.post/3me2lphed7k2c"
  },
  "description": "A bit over a year ago, in the first week of January 2025, I migrated my main Bluesky account to my own PDS on a Netcup VPS. It’s been quite easy to set up using the official installer, and it’s been running pretty much without any problems or maintenance the whole year.\n\nDespite that, I haven’t been 100% happy with this setup for one reason: Docker. So I decided to try to take it out of the box, and I made it run first on the same VPS installed separately, and then moved it to another machine this month with a clean install. This blog post is a guide to how I did this, if you’re interested. There are a few existing posts about this already:\n\nhttps://benharri.org/bluesky-pds-without-docker/\nhttps://char.lt/blog/2024/10/atproto-pds/\nhttps://cprimozic.net/notes/posts/notes-on-self-hosting-bluesky-pds-alongside-other-services/\n\nBut I figured it doesn’t hurt to make another one that does things slightly differently again. “There are many like this, but this one is mine”. (I mostly followed the benharri.org version.)\n\nNote: I’m describing what I did to migrate an existing PDS from in-Docker to outside-Docker, so I already had existing data and pds.env config; if you wanted to install one from scratch this way, you’d probably need to also set up the config manually.\n\n  \n\nYou might be asking: why? And that’s a good question. I mostly wouldn’t recommend this setup over the standard Docker one by default, unless you know what you’re doing. The standard installation is literally running one command and answering some questions, and then it auto-updates and manages everything.\n\nMy reason is that I’m generally pretty familiar with installing things on Linux servers manually, but I’m completely unfamiliar with Docker. I always wanted to do some modifications on the PDS, but I didn’t know how, because the Docker setup basically takes over the whole server for itself. I don’t know where it pulls code from, I don’t know where it puts it, and I don’t know when it can overwrite any changes I make. I don’t feel in control. (And to be clear, this is likely a me problem.)\n\nSo here’s what I did (this setup is for Ubuntu 24.04 Noble):\n\nInstall Nginx\n\nThe standard PDS distribution uses Caddy, but I use Nginx everywhere and I have configs built for it, so I’ve set up Nginx:\n\n# install Nginx\nsudo apt-get install --no-install-recommends nginx-light\n\n# enable HTTP on the firewall\nsudo ufw allow http/tcp\nsudo ufw allow https/tcp\n\n# if you haven't enabled ufw before:\nsudo ufw limit log ssh/tcp\nsudo ufw enable\n\nAlso here’s a standard thing I do on VPSes to let me install webapps in /var/www from my account:\n\n# set up environment for webapps\n\nsudo groupadd deploy\nsudo adduser psionides deploy\nsudo chown root:deploy /var/www\nsudo chmod 775 /var/www\n\nI also need Certbot for LetsEncrypt:\n\n# install Certbot\n\nsudo apt-get install --no-install-recommends certbot python3-certbot-nginx\nsudo certbot plugins --nginx --prepare\n\ncertbot plugins --nginx --prepare does some initial setup of …",
  "path": "/2026/02/04/pds-undockered/",
  "publishedAt": "2026-02-04T19:14:20Z",
  "site": "at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/site.standard.publication/3mn5mackuba26",
  "tags": [
    "Social"
  ],
  "textContent": "A bit over a year ago, in the first week of January 2025, I migrated my main Bluesky account to my own PDS on a Netcup VPS. It’s been quite easy to set up using the official installer, and it’s been running pretty much without any problems or maintenance the whole year.\n\nDespite that, I haven’t been 100% happy with this setup for one reason: Docker. So I decided to try to take it out of the box, and I made it run first on the same VPS installed separately, and then moved it to another machine this month with a clean install. This blog post is a guide to how I did this, if you’re interested. There are a few existing posts about this already:\n\nhttps://benharri.org/bluesky-pds-without-docker/\nhttps://char.lt/blog/2024/10/atproto-pds/\nhttps://cprimozic.net/notes/posts/notes-on-self-hosting-bluesky-pds-alongside-other-services/\n\nBut I figured it doesn’t hurt to make another one that does things slightly differently again. “There are many like this, but this one is mine”. (I mostly followed the benharri.org version.)\n\nNote: I’m describing what I did to migrate an existing PDS from in-Docker to outside-Docker, so I already had existing data and pds.env config; if you wanted to install one from scratch this way, you’d probably need to also set up the config manually.\n\n  \n\nYou might be asking: why? And that’s a good question. I mostly wouldn’t recommend this setup over the standard Docker one by default, unless you know what you’re doing. The standard installation is literally running one command and answering some questions, and then it auto-updates and manages everything.\n\nMy reason is that I’m generally pretty familiar with installing things on Linux servers manually, but I’m completely unfamiliar with Docker. I always wanted to do some modifications on the PDS, but I didn’t know how, because the Docker setup basically takes over the whole server for itself. I don’t know where it pulls code from, I don’t know where it puts it, and I don’t know when it can overwrite any changes I make. I don’t feel in control. (And to be clear, this is likely a me problem.)\n\nSo here’s what I did (this setup is for Ubuntu 24.04 Noble):\n\nInstall Nginx\n\nThe standard PDS distribution uses Caddy, but I use Nginx everywhere and I have configs built for it, so I’ve set up Nginx:\n\n# install Nginx\nsudo apt-get install --no-install-recommends nginx-light\n\n# enable HTTP on the firewall\nsudo ufw allow http/tcp\nsudo ufw allow https/tcp\n\n# if you haven't enabled ufw before:\nsudo ufw limit log ssh/tcp\nsudo ufw enable\n\nAlso here’s a standard thing I do on VPSes to let me install webapps in /var/www from my account:\n\n# set up environment for webapps\n\nsudo groupadd deploy\nsudo adduser psionides deploy\nsudo chown root:deploy /var/www\nsudo chmod 775 /var/www\n\nI also need Certbot for LetsEncrypt:\n\n# install Certbot\n\nsudo apt-get install --no-install-recommends certbot python3-certbot-nginx\nsudo certbot plugins --nginx --prepare\n\ncertbot plugins --nginx --prepare does some initial setup of some config files in /etc/letsencrypt that are unrelated to specific certificates.\n\nOne thing to note is that the standard setup with Caddy uses a .well-known route to verify any handles under *.yourdomain.com, and it automatically creates HTTPS certificates for those subdomains; this wouldn’t be as simple here, but I don’t need to be able to mass create new handles under my domain. If I ever need one or two, I’ll just set them up manually.\n\nEmail\n\nWe’ll also need email – you can use an external SMTP, but I like to use local sendmail that’s configured to forward emails to my main account:\n\n# install Postfix\nsudo apt-get install --no-install-recommends postfix\n\n  # choose: \"Internet site\"\n  # enter domain: \"lab.martianbase.net\"\n\n# set up the email forwarding\nsudo nano /etc/postfix/virtual\n\n  # add a line like this:\n  # kuba@lab.martianbase.net my.real.email@domain.com\n\nsudo nano /etc/postfix/main.cf\n\n  # add:\n  # virtual_alias_domains = lab.martianbase.net\n  # virtual_alias_maps = hash:/etc/postfix/virtual\n\nsudo postmap /etc/postfix/virtual\nsudo service postfix reload\n\n# enable SMTP on firewall\nsudo ufw allow smtp/tcp\n\nIf you set things up this way, you need to set the PDS_EMAIL_SMTP_URL in pds.env to smtp:///?sendmail=true.\n\nNote: you will probably need to set up a few things the right way in the DNS and might also need more tweaks in the /etc/postfix/main.cf for the email sending and forwarding to work. I honestly don’t understand the Postfix config well enough, I just have a setup that works and I don’t touch it, but it’s old and I don’t know how correct it is, so I won’t share it here. From my experience, it’s generally not super hard to configure self-hosted email in such way that it works for sending emails to you and only to you (like I have with my PDS and my Mastodon instance). If it goes to spam, you know where to look, and if you move it to the inbox, generally the email service should remember and whitelist the sender. (Sending emails to others is a whole different story of course.)\n\nNodeJS\n\nNext, NodeJS. I used asdf to install it (the version 0.15 is because in 0.16 they did a complete rewrite in Go from the original Bash version, and some things don’t work as before).\n\n# install asdf\ngit clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.15.0\nnano .bashrc\n\n  # add:\n  # source \"$HOME/.asdf/asdf.sh\"\n\n# ---\n# log out & back in here - this is also needed for the deploy group to take effect\n# ---\n\n# install node\nasdf plugin add nodejs\n\nNODE_VER=`asdf latest nodejs 22`\nasdf install nodejs $NODE_VER\nasdf global nodejs $NODE_VER\n\ncorepack enable\nasdf reshim nodejs\n\nThe Docker version runs on 20.x, but that’s almost EOL, so I’ve upgraded to the latest 22.x. 24.x doesn’t work at the moment, I get some ugly errors during installation.\n\nInstall the PDS\n\nNow the actual PDS code. I’ve decided to keep the code in /var/www, fetch it from git and use git pull for updates, and keep the data separately in /var/lib.\n\ncd /var/www\ngit clone https://github.com/bluesky-social/pds\ncd pds/service\n\npnpm install --production --frozen-lockfile\n\nMigrate the data\n\nNow it’s time to copy over the data from the previous setup.\n\nOn the first server I did it like this (remember to also turn off and disable the old PDS service in Docker):\n\nsudo rsync -rlpt /pds /var/lib/\nsudo chown -R psionides:psionides /var/lib/pds\n\nFor the second one, I made a temporary SSH key on the old server:\n\nssh-keygen -t ed25519 -f ~/.ssh/migration -N \"\" -C \"pds migration\"\n\nAdded it to authorized keys on the new one:\n\nnano ~/.ssh/authorized_keys\n\nPrepared an empty directory:\n\nsudo mkdir /var/lib/pds\nsudo chown psionides:psionides /var/lib/pds\n\nAnd rsynced the data from the old one to the new one:\n\n# install if missing on the target server:\nsudo apt-get install rsync\n\nrsync -rltv -e \"ssh -i ~/.ssh/migration\" /var/lib/pds/ newserver:/var/lib/pds/\n\nI tried to sync it from a local machine first, but I realized it would take much longer, and between two servers on the same network it took less than a minute for many GBs.\n\nStart the service\n\nWith the data copied, it’s time to finish the installation. First, an HTTPS certificate – for now, I made one using manual DNS registration before switching the DNS records:\n\nsudo certbot certonly --manual --preferred-challenges dns --key-type ecdsa \\\n    -m $myemail --agree-tos --no-eff-email -d lab.martianbase.net\n\nThe way it works is that it gives me a kind of verification token, and I need to put it in a TXT DNS record at _acme-challenge.lab.martianbase.net before pressing continue.\n\nThen, I added a systemd service at /etc/systemd/system/pds.service:\n\n[Unit]\nDescription=Bluesky PDS\nAfter=network.target\n\n[Service]\nType=simple\nUser=psionides\nWorkingDirectory=/var/www/pds/service\nExecStart=/home/psionides/.asdf/shims/node --enable-source-maps index.js\nRestart=on-failure\nEnvironmentFile=/var/lib/pds/pds.env\nEnvironment=\"NODE_ENV=production\"\nTimeoutSec=15\nRestart=on-failure\nRestartSec=1\nStandardOutput=append:/var/lib/pds/pds.log\n\n[Install]\nWantedBy=default.target\n\nI also told the PDS what port to run on in pds.env:\n\nPDS_PORT=3000\n\nAnd then enabled it like this:\n\nsudo systemctl daemon-reload\nsudo systemctl enable --now pds\n\nTest?\n\n$ curl http://localhost:3000\n\n         __                         __\n        /\\ \\__                     /\\ \\__\n    __  \\ \\ ,_\\  _____   _ __   ___\\ \\ ,_\\   ___\n  /'__'\\ \\ \\ \\/ /\\ '__'\\/\\''__\\/ __'\\ \\ \\/  / __'\\\n /\\ \\L\\.\\_\\ \\ \\_\\ \\ \\L\\ \\ \\ \\//\\ \\L\\ \\ \\ \\_/\\ \\L\\ \\\n \\ \\__/.\\_\\\\ \\__\\\\ \\ ,__/\\ \\_\\\\ \\____/\\ \\__\\ \\____/\n  \\/__/\\/_/ \\/__/ \\ \\ \\/  \\/_/ \\/___/  \\/__/\\/___/\n                   \\ \\_\\\n                    \\/_/\n\nIt’s working! Now, the Nginx config.\n\nNginx config for the PDS\n\nIn /etc/nginx/sites-available/pds.site:\n\n# this is needed to proxy the relevant HTTP headers for websocket\nmap $http_upgrade $connection_upgrade {\n  default upgrade;\n  ''      close;\n}\n\nupstream pds {\n  server 127.0.0.1:3000 fail_timeout=0;\n}\n\nserver {\n  server_name lab.martianbase.net;\n  listen 80;\n  listen [::]:80;  # ipv6\n\n  # redirect any http requests to https\n  location / {\n    return 301 https://$host$request_uri;\n  }\n\n  # except for certbot challenges\n  location /.well-known/acme-challenge/ {\n    root /var/www/html;\n  }\n}\n\nserver {\n  server_name lab.martianbase.net;\n  listen 443 ssl http2;\n  listen [::]:443 ssl http2;\n\n  ssl_certificate /etc/letsencrypt/live/lab.martianbase.net/fullchain.pem;\n  ssl_certificate_key /etc/letsencrypt/live/lab.martianbase.net/privkey.pem;\n\n  access_log /var/log/nginx/pds-access.log combined buffer=16k flush=10s;\n  error_log /var/log/nginx/pds-error.log;\n\n  client_max_body_size 100M;\n\n  location / {\n    include sites-available/proxy.inc;\n    proxy_pass http://pds;\n  }\n}\n\nIn /etc/nginx/sites-available/proxy.inc:\n\nproxy_set_header Host $host;\nproxy_set_header X-Real-IP $remote_addr;\nproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\nproxy_set_header X-Forwarded-Proto $scheme;\nproxy_set_header Origin \"\";\nproxy_set_header Proxy \"\";\n\nproxy_buffering off;\nproxy_redirect off;\nproxy_http_version 1.1;\nproxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection $connection_upgrade;\n\ntcp_nodelay on;\n\nThen:\n\nsudo ln -s /etc/nginx/sites-available/pds.site /etc/nginx/sites-enabled/\nsudo nginx -t\nsudo service nginx reload\n\nDNS\n\nAt this point it was time to update the DNS and wait…\n\nOnce it started working for me, I also had to wait a bit more for the existing relays to notice the IP change and I needed to poke them a few times to reconnect to me again, like this:\n\ncurl https://bsky.network/xrpc/com.atproto.sync.requestCrawl \\\n  --json '{\"hostname\": \"lab.martianbase.net\"}'\n\nRestarting the server with sudo service pds restart also sends a requestCrawl at least to bsky.network. What you want is to see in the pds.log that after pds has started it says request to com.atproto.sync.subscribeRepos. There was a brief moment when I could post something, but after reloading bsky.app I wouldn’t see my post, because the AppView hasn’t indexed it yet… thankfully, after the relay finally reconnected, it backfilled the missing events.\n\nI also updated the Certbot certificate again the normal way so I wouldn’t forget about it later:\n\nsudo certbot certonly --webroot --webroot-path /var/www/html \\\n    --key-type ecdsa -d lab.martianbase.net\n\n(There’s also a more automated Nginx verification method, where it adds the required certificate lines to your Nginx configs automatically, but I don’t like it because it makes a terrible mess of the configs…)\n\nGatekeeper\n\nThere was one more thing I did while I was already messing with the PDS, which was to add Bailey Townsend’s Gatekeeper service, which restores support for email-based 2FA. For some reason, the email 2FA was implemented in the “entryway” PDS bsky.social and not in the main code, and as of today it still hasn’t been added to the self-hosted PDS distribution… so Bailey decided that “we can just do things” and went and added it himself. You just need to install it separately (it’s written in Rust).\n\nFirst, we install the 🦀:\n\n# install Rust\n\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --profile minimal\n# (press enter)\n\nsource ~/.cargo/env\n\nI used rustup, because Gatekeeper requires a fairly new version of Rust and the one I had in Ubuntu LTS repo didn’t cut it.\n\nWe also need some additional dependencies for building:\n\n# some general compilers and stuff\nsudo apt-get install --no-install-recommends build-essential\n\n# openssl and pkg-config\nsudo apt-get install --no-install-recommends libssl-dev pkg-config\n\nNow, time to clone and build the service:\n\ngit clone https://tangled.org/baileytownsend.dev/pds-gatekeeper /var/www/gatekeeper\ncd /var/www/gatekeeper/\ncargo build --release\nsudo cp target/release/pds_gatekeeper /usr/local/bin/\n\n(It takes a bit of time to compile, go make yourself a coffee ☕️)\n\nThe current version is missing support for sendmail email transport, so I had to make some tweaks to make it work (see PR).\n\nThere are a couple of ENV entries we need to add to the pds.env:\n\nPDS_BASE_URL=http://localhost:3000\nGATEKEEPER_PORT=8000\n\nAnd again, we need to write a systemd service at /etc/systemd/system/gatekeeper.service:\n\n[Unit]\nDescription=Bluesky Gatekeeper\nAfter=network.target\n\n[Service]\nType=simple\nUser=psionides\nExecStart=/usr/local/bin/pds_gatekeeper\nRestart=on-failure\nEnvironment=\"PDS_ENV_LOCATION=/var/lib/pds/pds.env\"\nTimeoutSec=15\nRestart=on-failure\nRestartSec=1\nStandardOutput=append:/var/lib/pds/gatekeeper.log\n\n[Install]\nWantedBy=default.target\n\nAnd launch it:\n\nsudo systemctl daemon-reload\nsudo systemctl enable --now gatekeeper\n\nFinally, a few updates to the Nginx config:\n\nupstream gatekeeper {\n  server 127.0.0.1:8000 fail_timeout=0;\n}\n\nserver {\n  ...\n\n  location /xrpc/com.atproto.server.createSession {\n    include sites-available/proxy.inc;\n    proxy_pass http://gatekeeper;\n  }\n\n  location /xrpc/com.atproto.server.getSession {\n    include sites-available/proxy.inc;\n    proxy_pass http://gatekeeper;\n  }\n\n  location /xrpc/com.atproto.server.updateEmail {\n    include sites-available/proxy.inc;\n    proxy_pass http://gatekeeper;\n  }\n\n  location /@atproto/oauth-provider/~api/sign-in {\n    include sites-available/proxy.inc;\n    proxy_pass http://gatekeeper;\n  }\n}\n\nAnd reload it again:\n\nsudo nginx -t && sudo service nginx reload\n\nAt this point the PDS should be fully operational, including email 2FA when you try to log in 🎉\n\nFor creating accounts and other admin stuff, you can use the pdsadmin scripts from the source code dir, but you need to pass a PDS_ENV_FILE env var with the path to pds.env:\n\ncd /var/www/pds\nPDS_ENV_FILE=/var/lib/pds/pds.env bash pdsadmin/account.sh list\n\nBackups\n\nThere’s one last thing – it would also be nice to have backups of your nearly 3 years worth of Bluesky posts…\n\nI do it like this:\n\n# create a backup user\nsudo adduser archivist\n\n# add a separate SSH key from local Mac\nsudo mkdir /home/archivist/.ssh\necho \"ssh-ed25519 ... archivist@martianbase.net\" | sudo tee /home/archivist/.ssh/authorized_keys\nsudo chown -R archivist:archivist /home/archivist/.ssh\n\nsudo nano /usr/local/sbin/backup_pds:\n\n#!/bin/bash\n\nset -e\n\nservice pds stop\nrsync -rlpt --exclude=\"pds.log*\" /var/lib/pds/ /var/backups/pds\nchmod -R g+rX /var/backups/pds\nchown -R root:archivist /var/backups/pds\nservice pds start\n\nsudo chmod a+x /usr/local/sbin/backup_pds\n\nsudo nano /etc/cron.d/backup_pds:\n\n10 10 * * *   root   /usr/local/sbin/backup_pds\n\nAnd then I have a cron job on the local Mac which runs this backup script:\n\n#!/bin/bash\n\nSSH_COMMAND=\"ssh -i ~/.ssh/archivist\"\n\nrsync -rltq -e \"$SSH_COMMAND\" \"archivist@lab.martianbase.net:/var/backups/pds/\" pds\n\nThis way, at one point during the day the PDS data is copied to another folder on the server, shutting down the PDS for a moment to make sure the .sqlite files don’t end up corrupted, and then at some point later, my Mac separately rsyncs the copy of the data from that second folder, while the files aren’t being actively written to. This obviously doubles the data size requirements, so it wouldn’t be feasible for a larger PDS, but it’s totally fine on mine.\n\nAnd that’s it. A bit more than curl | bash, but I now control every piece of it and I can change them as I please. The old-school way, as God intended 😎",
  "title": "Running Bluesky PDS undockered",
  "updatedAt": "2026-02-04T19:14:22Z"
}