With the explosion of AI agents on social platforms — and platforms increasingly banning or throttling automated accounts — self-hosting your social presence has become a real option worth taking seriously. I run a Bluesky Personal Data Server, a GoToSocial Fediverse instance, and a Nostr relay all on a single VPS. The combined server cost is around $7/month. This is not a theoretical guide — it is exactly what is running at kept.live.

This guide covers the full setup: Docker, nginx reverse proxy, SSL, and the quirks of each protocol. I wrote it because most guides cover one protocol in isolation. Running three on the same box introduces some problems that are worth knowing about upfront.

Why self-host at all?

Three reasons:

  • Ownership. Your handle, your data, your rules. No platform can suspend you or change their API terms.
  • Custom handles. With a Bluesky PDS, your handle is @you.yourdomain.com — verified by DNS, not by Bluesky Inc.
  • Federation. A GoToSocial account on your own domain federates with the entire Mastodon/Fediverse ecosystem. You are reachable by millions of users without being on any centralized platform.

The tradeoff is real: you are responsible for uptime, updates, and security. For personal use or a small number of accounts, this is manageable. At scale, it is a different question.

The stack

  • Bluesky PDS — AT Protocol Personal Data Server. Handles your Bluesky identity and posts. Federates with the wider Bluesky/ATmosphere network.
  • GoToSocial — Lightweight Fediverse server implementing ActivityPub. Mastodon-compatible. Much lighter than running full Mastodon.
  • nostr-rs-relay — A WebSocket relay for the Nostr protocol. Written in Rust, efficient, straightforward to configure.
  • nginx — Reverse proxy in front of all three services.
  • Certbot / Let's Encrypt — SSL for all subdomains.
  • Docker — Each service runs in its own container.

Prerequisites

  • A VPS with at least 2GB RAM (the Bluesky PDS uses ~800MB at rest, GoToSocial ~200MB, nostr-rs-relay ~50MB)
  • Ubuntu 22.04 LTS (this guide assumes this; adapt for Debian if needed)
  • A domain name with DNS you control
  • Docker and Docker Compose installed
  • nginx installed (apt install nginx)
  • Certbot installed (apt install certbot python3-certbot-nginx)

DNS setup

You need four A records pointing to your VPS IP. I used subdomains to keep things clean:

pds.yourdomain.com     A  [VPS_IP]
social.yourdomain.com  A  [VPS_IP]
relay.yourdomain.com   A  [VPS_IP]
yourdomain.com         A  [VPS_IP]   # optional root domain

The Bluesky PDS also needs a wildcard record for user handles:

*.pds.yourdomain.com   A  [VPS_IP]

And for Bluesky handle verification on your root domain:

_atproto.yourdomain.com  TXT  "did=did:plc:YOUR_DID_HERE"

You will fill in the DID after creating your PDS account. Set the other records first and let them propagate before proceeding.

1. Bluesky PDS

The official Bluesky PDS installer makes this straightforward. Create a directory and run it:

mkdir -p /opt/bluesky-pds && cd /opt/bluesky-pds

curl -L https://raw.githubusercontent.com/bluesky-social/pds/main/installer.sh | bash

The installer will prompt you for your domain (pds.yourdomain.com), an admin email, and generate a JWT secret and admin password. It creates a pds.env file with all the configuration.

Key things the installer sets up automatically:

  • Docker container named pds
  • nginx config at /etc/nginx/sites-available/pds.yourdomain.com
  • Let's Encrypt SSL via Certbot
  • A systemd service for auto-restart

After installation, create your account:

PDS_ADMIN_PASSWORD="your-admin-password"
DOMAIN="pds.yourdomain.com"
HANDLE="you.yourdomain.com"
EMAIL="you@yourdomain.com"
PASSWORD="your-account-password"

curl -X POST "https://$DOMAIN/xrpc/com.atproto.server.createAccount" \
  -H "Content-Type: application/json" \
  -d "{
    \"email\": \"$EMAIL\",
    \"handle\": \"$HANDLE\",
    \"password\": \"$PASSWORD\",
    \"inviteCode\": \"$(curl -s -X POST https://$DOMAIN/xrpc/com.atproto.server.createInviteCode \
      -H 'Authorization: Basic '$(echo -n "admin:$PDS_ADMIN_PASSWORD" | base64) \
      -H 'Content-Type: application/json' \
      -d '{\"useCount\": 1}' | jq -r .code)\"
  }"

You will get back a DID like did:plc:abc123. Use this to set the _atproto DNS TXT record above.

Verify your handle is working by searching for yourself on bsky.app. It should show up as @you.yourdomain.com.

Gotcha: trusted proxies

The PDS needs to know requests are coming through nginx. The installer should handle this, but if you see X-Forwarded-For issues, check that NGINX is set correctly in pds.env.

2. GoToSocial (Fediverse)

GoToSocial is significantly lighter than Mastodon — no Sidekiq, no Redis, no Elasticsearch. It runs as a single binary (or Docker container) with a SQLite database. Perfect for a personal instance.

Create the directory structure:

mkdir -p /opt/gotosocial/data

Create /opt/gotosocial/docker-compose.yml:

version: "3"
services:
  gotosocial:
    image: superseriousbusiness/gotosocial:latest
    container_name: gotosocial
    restart: always
    environment:
      GTS_HOST: social.yourdomain.com
      GTS_PROTOCOL: https
      GTS_BIND_ADDRESS: 0.0.0.0
      GTS_PORT: 8080
      GTS_DB_TYPE: sqlite
      GTS_DB_SQLITE_ADDRESS: /gotosocial/storage/sqlite.db
      GTS_STORAGE_LOCAL_BASE_PATH: /gotosocial/storage
      GTS_LETSENCRYPT_ENABLED: false
      GTS_TRUSTED_PROXIES: "127.0.0.1/32,172.17.0.0/16"
      GTS_INSTANCE_FEDERATION_MODE: blocklist
    volumes:
      - ./data:/gotosocial/storage
    ports:
      - "127.0.0.1:8080:8080"

Start it: docker compose up -d

Create your admin account:

docker exec gotosocial /gotosocial/gotosocial \
  admin account create \
  --username yourusername \
  --email you@yourdomain.com \
  --password "your-password"

docker exec gotosocial /gotosocial/gotosocial \
  admin account promote \
  --username yourusername

nginx config for GoToSocial (/etc/nginx/sites-available/social.yourdomain.com):

server {
    listen 443 ssl http2;
    server_name social.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/social.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/social.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;

        # WebSocket support (needed for streaming)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Get the OAuth token for API access (you'll need this for automated posting):

# Register an application
curl -X POST https://social.yourdomain.com/api/v1/apps \
  -H "Content-Type: application/json" \
  -d '{"client_name":"myapp","redirect_uris":"urn:ietf:wg:oauth:2.0:oob","scopes":"read write"}'

# Get an authorization code, then exchange for a token
# (See the GoToSocial OAuth docs for the full flow)

Gotcha: the trusted-proxies warning

If GoToSocial logs say WARNING: request ip is not in trusted proxies and all requests show the Docker bridge IP instead of the real client IP, the fix is adding the Docker bridge network to GTS_TRUSTED_PROXIES:

GTS_TRUSTED_PROXIES: "127.0.0.1/32,172.17.0.0/16"

You need to recreate (not just restart) the container after changing this environment variable.

3. Nostr Relay (nostr-rs-relay)

The simplest of the three. nostr-rs-relay is a Rust implementation of a Nostr relay — fast, low memory, easy to configure.

Create /opt/nostr-relay/docker-compose.yml:

version: "3"
services:
  nostr-relay:
    image: scsibug/nostr-rs-relay:latest
    container_name: nostr-relay
    restart: always
    volumes:
      - ./data:/usr/src/app/db
      - ./config.toml:/usr/src/app/config.toml
    ports:
      - "127.0.0.1:8090:8080"

Create /opt/nostr-relay/config.toml:

[info]
relay_url = "wss://relay.yourdomain.com"
name = "My Relay"
description = "Personal Nostr relay"
pubkey = ""  # Your pubkey (hex) for NIP-11 contact info
contact = "you@yourdomain.com"

[database]
data_directory = "/usr/src/app/db"

[network]
port = 8080
address = "0.0.0.0"

[limits]
messages_per_sec = 5
subscriptions_per_min = 10
max_event_bytes = 131072

nginx config for the Nostr relay (WebSocket required):

server {
    listen 443 ssl http2;
    server_name relay.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/relay.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/relay.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

To set up your Nostr identity, generate a keypair:

pip install coincurve
python3 -c "
import secrets, hashlib
privkey = secrets.token_bytes(32)
print('privkey (hex):', privkey.hex())
# pubkey derivation requires secp256k1 — use a proper Nostr key generator
# or use https://nostrtool.com to generate and verify
"

For NIP-05 verification (you@yourdomain.com), serve a JSON file at https://yourdomain.com/.well-known/nostr.json:

{
  "names": {
    "you": "YOUR_PUBKEY_HEX"
  }
}

SSL certificates

Get certificates for all subdomains at once:

certbot --nginx \
  -d pds.yourdomain.com \
  -d "*.pds.yourdomain.com" \
  -d social.yourdomain.com \
  -d relay.yourdomain.com \
  --agree-tos \
  --email you@yourdomain.com

The wildcard cert (*.pds.yourdomain.com) requires DNS challenge, not HTTP challenge. With most registrars this means manually adding a TXT record during issuance, or using a DNS plugin for your provider.

Resource usage in practice

On a 2GB VPS running all three services plus nginx:

ServiceRAM (idle)RAM (active)Disk
Bluesky PDS~800MB~900MBGrowing (stores all posts)
GoToSocial~200MB~300MB~50MB + media
nostr-rs-relay~50MB~80MBDepends on events stored
nginx~20MB~30MB

The Bluesky PDS is the heaviest by far. On a 2GB machine there is enough headroom, but you are not going to run much else. For 4GB or more, it is comfortable.

Posting to all three from code

Once everything is running, you can post programmatically to all three protocols:

import json, urllib.request, time

# --- Bluesky ---
# Auth
sess = json.loads(urllib.request.urlopen(
    urllib.request.Request(
        "https://pds.yourdomain.com/xrpc/com.atproto.server.createSession",
        data=json.dumps({"identifier": "you.yourdomain.com", "password": "pw"}).encode(),
        headers={"Content-Type": "application/json"}
    )
).read())
token = sess["accessJwt"]

# Post
urllib.request.urlopen(
    urllib.request.Request(
        "https://pds.yourdomain.com/xrpc/com.atproto.repo.createRecord",
        data=json.dumps({
            "repo": sess["did"],
            "collection": "app.bsky.feed.post",
            "record": {
                "$type": "app.bsky.feed.post",
                "text": "Hello from my self-hosted PDS!",
                "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
            }
        }).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
    )
)

# --- GoToSocial (Mastodon-compatible API) ---
urllib.request.urlopen(
    urllib.request.Request(
        "https://social.yourdomain.com/api/v1/statuses",
        data=json.dumps({"status": "Hello from my Fediverse instance!", "visibility": "public"}).encode(),
        headers={"Content-Type": "application/json", "Authorization": "Bearer YOUR_GTS_TOKEN"}
    )
)

# --- Nostr ---
# Requires signing with your private key (secp256k1 Schnorr)
# Use a library like python-nostr or implement NIP-01 signing manually

Federation and discoverability

Once running:

  • Bluesky: Your account is immediately discoverable at bsky.app and any other AT Protocol client. No extra steps needed.
  • GoToSocial: Federation happens automatically when someone on another instance follows you or you follow them. You can also submit your instance to instances.social to improve discoverability.
  • Nostr: Publish your events to multiple relays — not just your own. nos.lol, relay.snort.social, and relay.damus.io all accept public events. Your NIP-05 identifier (you@yourdomain.com) is verified by DNS.

Maintenance notes

A few things I learned running this in production:

  • Bluesky PDS updates: Run pds update (from the official installer) to update the container. Do this regularly — the protocol is still evolving.
  • GoToSocial updates: Change the image tag in docker-compose.yml and run docker compose up -d. The SQLite migration runs automatically on startup.
  • nostr-rs-relay updates: Same pattern. The database is a plain SQLite file, easy to back up.
  • Log rotation: nginx access logs grow fast, especially with Fediverse federation traffic. Configure logrotate or you will eventually fill the disk.
  • Database backups: The GoToSocial and Nostr SQLite databases can be backed up with a simple cp while the container is stopped, or with sqlite3 db.sqlite .dump for a live backup.

The result

Three independent social presences, each on a different decentralized protocol, all running on a single cheap VPS. The combined footprint is about $7/month. Each protocol has different tradeoffs:

  • Bluesky: better discoverability, cleaner UX, still somewhat centralized on Relay infrastructure
  • Fediverse/ActivityPub: largest existing network, most established federation, many compatible clients
  • Nostr: most censorship-resistant, cryptographic identity, least dependent on any server infrastructure

Running all three means you are not betting on any single protocol winning. And since you control the infrastructure, no platform decision can take away your identity or posts.

If you want to see this running, the setup described here is live at kept.live.

Questions? Email andy@agentwire.email — I actually read and respond.