Federated Forum
    ActivityPub

    Deploy Lemmy on a VPS

    Self-host Lemmy, a federated ActivityPub link aggregator, on a RamNode KVM VPS — Docker Compose, content-negotiation proxy, PostgreSQL tuning, TLS, and federation checks.

    Lemmy is a federated link aggregator and discussion platform. It speaks ActivityPub, so your instance can exchange posts, comments, and votes with Mastodon, Kbin, PieFed, and every other Lemmy instance on the network. Unlike a standalone forum, Lemmy is not a single application. It is a Rust backend, a server-rendered Node frontend, a PostgreSQL database, and a separate image-processing service, all sitting behind a reverse proxy that has to route requests based on content negotiation.

    That last part is what trips people up. A browser and a federating server request the exact same URL and expect completely different responses. Get the routing wrong and your instance looks fine in a browser while silently failing to federate.

    This guide covers a production Lemmy deployment on a single RamNode KVM VPS: Docker Compose stack, the content-negotiation proxy, PostgreSQL tuning, external SMTP relay, TLS, backups, and the federation checks that tell you whether the instance is actually working.


    Before you start

    Pick the right RamNode plan

    Lemmy is heavier than its interface suggests. PostgreSQL does the bulk of the work, and federation means your database absorbs writes from every instance you subscribe to, not just from your own users.

    Instance typeRecommended specs
    Single-user or small private instance, few subscriptions4 GB RAM, 2 vCPU, 50 GB NVMe
    Small public instance, tens of active users, moderate federation8 GB RAM, 4 vCPU, 100 GB NVMe
    Larger public instance, heavy subscription list16 GB RAM, 4+ vCPU, 200 GB NVMe

    Do not attempt this on 2 GB. The Rust backend and the Node UI together will consume most of it before PostgreSQL gets a look in, and the first federation backfill will OOM the box.

    Storage matters more than you expect. Federated images cached by pict-rs grow steadily and quietly. Budget for growth and pick an NVMe plan, since PostgreSQL under federation load is IOPS-bound.

    Deploy in the region closest to your intended user base. RamNode's NYC and Atlanta locations are reasonable defaults for US-focused instances, Amsterdam for European ones.

    DNS

    Federation identity is derived from your domain and it is permanent. Once your instance federates under lemmy.example.com, that name is baked into every ActivityPub object other instances have stored about you. There is no supported rename path. Decide now.

    Create an A record pointing to your VPS IPv4:

    shell
    lemmy.example.com.  A  198.51.100.42

    Add an AAAA record if RamNode has assigned you IPv6:

    shell
    lemmy.example.com.  AAAA  2001:db8::1

    Set reverse DNS for the IP in the RamNode control panel to match. It is not strictly required for federation but it is good hygiene and some instances log it.

    Confirm propagation before you touch TLS:

    shell
    dig +short lemmy.example.com A
    dig +short lemmy.example.com AAAA

    Email is not optional, and you cannot run it locally

    Lemmy needs SMTP for account verification and password resets. RamNode does not permit mail services on their VPS and outbound port 25 is blocked, so running a local Postfix is off the table.

    Use an external SMTP relay on port 587. Postmark, Mailgun, SendGrid, Amazon SES, and Fastmail all work. Set the relay up and have credentials in hand before you configure Lemmy. You will need:

    • SMTP hostname and port (587, STARTTLS)
    • SMTP username and password
    • A verified sending address, for example noreply@example.com

    Verify the SPF and DKIM records your relay provides. Password reset mail that lands in spam generates support tickets forever.


    Step 1: Base system preparation

    Start from a clean Ubuntu 24.04 LTS install via the RamNode control panel. Connect over SSH.

    Update and install the essentials:

    shell
    apt update && apt upgrade -y
    apt install -y curl ca-certificates gnupg ufw fail2ban unattended-upgrades postgresql-client-16 jq

    Set the hostname

    shell
    hostnamectl set-hostname lemmy.example.com

    Add it to /etc/hosts:

    shell
    echo "127.0.1.1 lemmy.example.com lemmy" >> /etc/hosts

    Create an admin user and lock down SSH

    shell
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    Edit /etc/ssh/sshd_config:

    shell
    PermitRootLogin no
    PasswordAuthentication no
    PubkeyAuthentication yes

    Reload:

    shell
    systemctl reload ssh

    Add swap

    Federation backfill produces memory spikes. Swap is your insurance against the OOM killer taking out PostgreSQL mid-transaction.

    shell
    fallocate -l 4G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile
    swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab

    Lower the swappiness so it stays a safety net rather than a performance drag:

    shell
    echo 'vm.swappiness=10' >> /etc/sysctl.d/99-lemmy.conf
    sysctl --system

    Firewall

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    Nothing else needs to be exposed. Every service in the stack binds to the Docker network or to localhost.

    Automatic security updates

    shell
    dpkg-reconfigure --priority=low unattended-upgrades

    Step 2: Install Docker

    Use Docker's official repository, not the Ubuntu package. The distro version lags badly and the Compose plugin is what you want.

    shell
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    chmod a+r /etc/apt/keyrings/docker.asc
    
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
    https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
      > /etc/apt/sources.list.d/docker.list
    
    apt update
    apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

    Add your deploy user to the docker group:

    shell
    usermod -aG docker deploy

    Verify:

    shell
    docker --version
    docker compose version

    Cap the log growth

    Docker's default json-file driver will happily fill your disk. Create /etc/docker/daemon.json:

    shell
    {
      "log-driver": "json-file",
      "log-opts": {
        "max-size": "50m",
        "max-file": "3"
      }
    }
    shell
    systemctl restart docker

    Step 3: Lay out the stack

    Log in as deploy from here on.

    shell
    mkdir -p ~/lemmy/volumes/{postgres,pictrs,lemmy-ui}
    cd ~/lemmy

    pict-rs runs as UID/GID 991 inside its container and will not create its own directory permissions:

    shell
    sudo chown -R 991:991 ~/lemmy/volumes/pictrs

    Generate your secrets now and keep them somewhere safe:

    shell
    openssl rand -hex 32   # postgres password
    openssl rand -hex 32   # pict-rs API key
    openssl rand -base64 24 # admin password

    Step 4: PostgreSQL tuning

    Lemmy's default PostgreSQL settings are the container image defaults, which are tuned for a laptop. On a federating instance this is the single biggest source of slowness.

    Create ~/lemmy/customPostgresql.conf. The values below assume 8 GB RAM. Scale shared_buffers to roughly 25 percent of system RAM and effective_cache_size to roughly 50 percent.

    shell
    # Connections
    max_connections = 100
    
    # Memory
    shared_buffers = 2GB
    effective_cache_size = 4GB
    maintenance_work_mem = 512MB
    work_mem = 16MB
    
    # Write behavior
    wal_buffers = 16MB
    checkpoint_completion_target = 0.9
    min_wal_size = 1GB
    max_wal_size = 4GB
    synchronous_commit = off
    
    # NVMe planner hints
    random_page_cost = 1.1
    effective_io_concurrency = 200
    
    # Parallelism
    max_worker_processes = 4
    max_parallel_workers_per_gather = 2
    max_parallel_workers = 4
    max_parallel_maintenance_workers = 2
    
    # Autovacuum, aggressive because federation writes constantly
    autovacuum = on
    autovacuum_max_workers = 3
    autovacuum_naptime = 20s
    autovacuum_vacuum_scale_factor = 0.05
    autovacuum_analyze_scale_factor = 0.02
    
    # Logging
    log_min_duration_statement = 2000
    log_checkpoints = on
    log_autovacuum_min_duration = 0
    
    # Required so the container can listen on the Docker network
    listen_addresses = '*'

    A note on synchronous_commit = off: this trades a small window of potential transaction loss on hard power failure for a large throughput gain. For a social instance this is almost always the right call. If your instance is doing something where losing the last few hundred milliseconds of comments is unacceptable, set it back to on and accept the write latency.


    Step 5: Lemmy configuration

    Create ~/lemmy/lemmy.hjson. Substitute your real values.

    shell
    {
      database: {
        host: postgres
        port: 5432
        user: "lemmy"
        password: "YOUR_POSTGRES_PASSWORD"
        database: "lemmy"
        pool_size: 10
      }
    
      # This is your federation identity. It is permanent.
      hostname: "lemmy.example.com"
    
      bind: "0.0.0.0"
      port: 8536
    
      # Set false once you are done with initial setup if you do not
      # want the instance reachable over plain HTTP internally.
      tls_enabled: true
    
      pictrs: {
        url: "http://pictrs:8080/"
        api_key: "YOUR_PICTRS_API_KEY"
        # Cache remote images locally. Costs disk, saves bandwidth
        # and keeps images alive if the origin instance vanishes.
        cache_remote_images: true
      }
    
      email: {
        smtp_server: "smtp.postmarkapp.com:587"
        smtp_login: "YOUR_SMTP_USERNAME"
        smtp_password: "YOUR_SMTP_PASSWORD"
        smtp_from_address: "Lemmy <noreply@example.com>"
        tls_type: "starttls"
      }
    
      setup: {
        admin_username: "admin"
        admin_password: "YOUR_ADMIN_PASSWORD"
        admin_email: "you@example.com"
        site_name: "Example Lemmy"
      }
    }

    Lock it down. It contains three sets of credentials:

    shell
    chmod 600 ~/lemmy/lemmy.hjson

    The setup block only runs on first boot against an empty database. After the initial admin account exists it is ignored, and you can remove it. If you leave it in place it does no harm but it is one more file holding a plaintext admin password.


    Step 6: The internal proxy configuration

    This is the part that matters. Lemmy needs a proxy that inspects the request and decides whether it is a browser asking for HTML or a remote server asking for ActivityPub JSON.

    The rules:

    • Requests carrying Accept: application/activity+json or application/ld+json go to the backend.
    • All POST requests go to the backend.
    • /api, /pictrs, /feeds, /nodeinfo, /.well-known/webfinger, /.well-known/nodeinfo go to the backend.
    • Everything else goes to the UI.

    Create ~/lemmy/nginx_internal.conf:

    shell
    worker_processes 1;
    
    events {
        worker_connections 1024;
    }
    
    http {
        upstream lemmy {
            server "lemmy:8536";
        }
        upstream lemmy-ui {
            server "lemmy-ui:1234";
        }
    
        server {
            listen 8536;
            server_name localhost;
            server_tokens off;
    
            gzip on;
            gzip_types text/css application/javascript image/svg+xml;
            gzip_vary on;
    
            client_max_body_size 20M;
    
            add_header X-Frame-Options SAMEORIGIN;
            add_header X-Content-Type-Options nosniff;
            add_header X-XSS-Protection "1; mode=block";
    
            # Browser traffic to the UI by default.
            location / {
                set $proxpass "http://lemmy-ui";
    
                # ActivityPub content negotiation. A remote server
                # asking for JSON at the same URL a browser asks for
                # HTML must reach the backend.
                if ($http_accept ~ "^application/.*quot;) {
                    set $proxpass "http://lemmy";
                }
    
                # All writes are API calls.
                if ($request_method = POST) {
                    set $proxpass "http://lemmy";
                }
    
                proxy_pass $proxpass;
    
                rewrite ^(.+)/+$ $1 permanent;
    
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header Host $host;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    
                # Federation activities can take a while to process.
                proxy_read_timeout 300s;
                proxy_connect_timeout 75s;
    
                # Websockets for the UI.
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
            }
    
            # Unconditional backend routes.
            location ~ ^/(api|pictrs|feeds|nodeinfo|version|.well-known) {
                proxy_pass "http://lemmy";
    
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
    
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header Host $host;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    
                proxy_read_timeout 300s;
            }
        }
    }

    The if ($http_accept ~ "^application/.*quot;) match is deliberately broad. It catches both application/activity+json and application/ld+json; profile="https://www.w3.org/ns/activitystreams" without needing to enumerate every variant remote implementations send.


    Step 7: The Compose stack

    Create ~/lemmy/docker-compose.yml. Pin your versions. Do not use latest on a federating service, because a surprise major version bump during an unattended pull will break federation with instances that have not upgraded.

    shell
    name: lemmy
    
    x-logging: &default-logging
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"
    
    services:
      proxy:
        image: nginx:1.27-alpine
        restart: always
        ports:
          # Bind to localhost only. The public reverse proxy on the
          # host is what terminates TLS and faces the internet.
          - "127.0.0.1:8536:8536"
        volumes:
          - ./nginx_internal.conf:/etc/nginx/nginx.conf:ro,Z
        depends_on:
          - pictrs
          - lemmy-ui
        logging: *default-logging
    
      lemmy:
        image: dessalines/lemmy:0.19.11
        hostname: lemmy
        restart: always
        environment:
          - RUST_LOG=warn
          - RUST_BACKTRACE=1
        volumes:
          - ./lemmy.hjson:/config/config.hjson:ro,Z
        depends_on:
          postgres:
            condition: service_healthy
          pictrs:
            condition: service_started
        logging: *default-logging
    
      lemmy-ui:
        image: dessalines/lemmy-ui:0.19.11
        restart: always
        environment:
          - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy:8536
          - LEMMY_UI_LEMMY_EXTERNAL_HOST=lemmy.example.com
          - LEMMY_UI_HTTPS=true
        volumes:
          - ./volumes/lemmy-ui/extra_themes:/app/extra_themes
        depends_on:
          - lemmy
        logging: *default-logging
    
      pictrs:
        image: asonix/pictrs:0.5.17
        hostname: pictrs
        restart: always
        user: 991:991
        environment:
          - PICTRS__SERVER__API_KEY=YOUR_PICTRS_API_KEY
          - PICTRS__MEDIA__VIDEO__VIDEO_CODEC=vp9
          - PICTRS__MEDIA__VIDEO__MAX_FILE_SIZE=20
          - PICTRS__MEDIA__IMAGE__MAX_FILE_SIZE=20
          - PICTRS__MEDIA__ANIMATION__MAX_WIDTH=256
          - PICTRS__MEDIA__ANIMATION__MAX_HEIGHT=256
          - PICTRS__MEDIA__ANIMATION__MAX_FRAME_COUNT=400
          - RUST_LOG=warn
        volumes:
          - ./volumes/pictrs:/mnt:Z
        deploy:
          resources:
            limits:
              memory: 1G
        logging: *default-logging
    
      postgres:
        image: pgautoupgrade/pgautoupgrade:16-alpine
        hostname: postgres
        restart: always
        environment:
          - POSTGRES_USER=lemmy
          - POSTGRES_PASSWORD=YOUR_POSTGRES_PASSWORD
          - POSTGRES_DB=lemmy
        volumes:
          - ./volumes/postgres:/var/lib/postgresql/data:Z
          - ./customPostgresql.conf:/etc/postgresql.conf:Z
        command: postgres -c config_file=/etc/postgresql.conf
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U lemmy -d lemmy"]
          interval: 10s
          timeout: 5s
          retries: 5
        shm_size: 256mb
        logging: *default-logging

    Two things worth calling out.

    pgautoupgrade rather than the stock postgres image: Lemmy upgrades occasionally bring a PostgreSQL major version bump with them. The stock image refuses to start on a data directory from an older major and you get to do a manual dump-and-restore under pressure. pgautoupgrade handles it in place on first boot.

    shm_size: 256mb: PostgreSQL uses shared memory for parallel query workers. Docker's 64 MB default causes intermittent failures on larger queries that are miserable to diagnose.

    Bring it up:

    shell
    cd ~/lemmy
    docker compose up -d
    docker compose logs -f lemmy

    Watch for the migrations to run and the backend to bind. First start takes a minute or two while it builds the schema.

    Verify locally before you put TLS in front of it:

    shell
    curl -s http://127.0.0.1:8536/api/v3/site | jq '.site_view.site.name'

    That should return your site name. If it does, the backend, the database, and the internal proxy are all talking.


    Step 8: Public reverse proxy and TLS

    Install Caddy on the host. It handles certificate issuance and renewal without you thinking about it, and Lemmy's routing complexity is already handled by the internal nginx.

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
      | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
      | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update
    sudo apt install -y caddy

    Replace /etc/caddy/Caddyfile:

    shell
    lemmy.example.com {
        encode gzip
    
        # Attachments and image uploads.
        request_body {
            max_size 20MB
        }
    
        reverse_proxy 127.0.0.1:8536 {
            # Federation deliveries and large image processing
            # can exceed the default.
            transport http {
                read_timeout 300s
                write_timeout 300s
            }
        }
    
        header {
            Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
            X-Content-Type-Options "nosniff"
            Referrer-Policy "strict-origin-when-cross-origin"
            -Server
        }
    
        log {
            output file /var/log/caddy/lemmy.log {
                roll_size 100mb
                roll_keep 5
            }
        }
    }
    shell
    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy

    Caddy will fetch a Let's Encrypt certificate on first request. Confirm:

    shell
    curl -sI https://lemmy.example.com | head -1

    Open the site in a browser and log in with the admin credentials from lemmy.hjson.


    Step 9: Verify federation actually works

    The site loading in a browser proves nothing about federation. Test it properly.

    Check that ActivityPub content negotiation is routing correctly

    shell
    curl -s -H "Accept: application/activity+json" \
      https://lemmy.example.com/u/admin | jq '.type, .preferredUsername'

    You should get back "Person" and "admin". If you get HTML, your proxy is routing federation requests to the UI and no other instance will be able to see you.

    Check webfinger

    shell
    curl -s "https://lemmy.example.com/.well-known/webfinger?resource=acct:admin@lemmy.example.com" | jq

    Check nodeinfo

    shell
    curl -s https://lemmy.example.com/nodeinfo/2.0.json | jq '.software'

    Test an actual federation round trip

    From the search box in your instance, search for a known remote community using its full address, for example !technology@lemmy.world. Select the "All" or "Communities" filter. If the community resolves and you can subscribe to it, outbound federation is working. Give it a few minutes and posts should start arriving.

    If it does not resolve, check the backend logs:

    shell
    docker compose logs lemmy | grep -i federat

    The most common causes are a hostname mismatch in lemmy.hjson, the content-negotiation rule not firing, or a firewall blocking outbound HTTPS.


    Step 10: Post-install configuration

    Log in as admin and open the admin settings.

    Registration mode. The default is open. Unless you want to spend your evenings deleting spam accounts, switch to "Require application" and write an application question. This single setting does more for instance health than anything else on the page.

    Email verification. Turn it on. This is why you configured SMTP.

    Captcha. Enable it if you keep registrations open.

    Federation. Decide early whether you want an allowlist or a blocklist. A blocklist federates with everything except named instances and is the default posture. An allowlist federates only with instances you name and is appropriate for private or tightly scoped instances. Changing from blocklist to allowlist later will silently orphan a lot of existing content.

    Rate limits. The defaults are reasonable. Tighten the registration and image limits if you get scraped.

    Slur filter. A regex applied to posts and comments. Useful, but test your regex, because a greedy pattern here will reject legitimate content and confuse your users.


    Step 11: Backups

    Your database is the instance. The images are replaceable. Back up accordingly.

    Create /home/deploy/lemmy-backup.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    BACKUP_DIR="/home/deploy/backups"
    STACK_DIR="/home/deploy/lemmy"
    STAMP=$(date +%Y%m%d-%H%M%S)
    RETAIN_DAYS=14
    
    mkdir -p "$BACKUP_DIR"
    
    # Database. Custom format so you get parallel restore and
    # selective table recovery.
    docker compose -f "$STACK_DIR/docker-compose.yml" exec -T postgres \
      pg_dump -U lemmy -Fc lemmy > "$BACKUP_DIR/lemmy-db-$STAMP.dump"
    
    # Configuration. Small, and painful to reconstruct from memory.
    tar czf "$BACKUP_DIR/lemmy-config-$STAMP.tar.gz" \
      -C "$STACK_DIR" lemmy.hjson docker-compose.yml nginx_internal.conf customPostgresql.conf
    
    # Images. Large. Weekly is usually enough, adjust to taste.
    if [ "$(date +%u)" = "7" ]; then
      tar czf "$BACKUP_DIR/lemmy-pictrs-$STAMP.tar.gz" -C "$STACK_DIR/volumes" pictrs
    fi
    
    find "$BACKUP_DIR" -name 'lemmy-*' -mtime +$RETAIN_DAYS -delete
    
    echo "Backup complete: $STAMP"
    shell
    chmod +x /home/deploy/lemmy-backup.sh

    Schedule it:

    shell
    crontab -e
    shell
    15 3 * * * /home/deploy/lemmy-backup.sh >> /home/deploy/backup.log 2>&1

    Push the results off the box. A backup on the same VPS protects you from a bad upgrade and nothing else. RamNode's snapshot feature is a useful second layer for whole-machine recovery but it is not a substitute for tested database dumps.

    Restore procedure

    Practice this before you need it.

    shell
    cd ~/lemmy
    docker compose stop lemmy lemmy-ui proxy
    docker compose exec -T postgres dropdb -U lemmy lemmy
    docker compose exec -T postgres createdb -U lemmy lemmy
    docker compose exec -T postgres pg_restore -U lemmy -d lemmy --clean --if-exists \
      < ~/backups/lemmy-db-20260716-031500.dump
    docker compose up -d

    Step 12: Maintenance

    Upgrading

    Read the release notes. Lemmy point releases sometimes carry database migrations that cannot be rolled back.

    shell
    cd ~/lemmy
    ./lemmy-backup.sh              # always
    # edit docker-compose.yml, bump lemmy and lemmy-ui to the same version
    docker compose pull
    docker compose up -d
    docker compose logs -f lemmy

    Keep lemmy and lemmy-ui on matching versions. Mismatched versions produce API errors that look like frontend bugs.

    Watch the database size

    shell
    docker compose exec postgres psql -U lemmy -d lemmy -c \
      "SELECT pg_size_pretty(pg_database_size('lemmy'));"

    And the biggest tables:

    shell
    docker compose exec postgres psql -U lemmy -d lemmy -c \
      "SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
       FROM pg_catalog.pg_statio_user_tables
       ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;"

    On a federating instance received_activity grows relentlessly. Recent Lemmy versions prune it automatically. If yours does not, or it has fallen behind:

    shell
    docker compose exec postgres psql -U lemmy -d lemmy -c \
      "DELETE FROM received_activity WHERE published < now() - interval '7 days';"

    Watch pict-rs disk usage

    shell
    du -sh ~/lemmy/volumes/pictrs

    If cache_remote_images is on, this grows with every federated image your instance renders. pict-rs has its own cleanup tooling, but the pragmatic answer on a VPS is to monitor it and size your disk accordingly.

    Federation queue health

    Lemmy exposes queue state in the admin panel under the federation section. If outbound activities to a particular instance are backing up, that instance is usually down or blocking you. It is rarely your problem to fix.


    Troubleshooting

    Site loads but nothing federates. Run the content negotiation check from Step 9. If a request with Accept: application/activity+json returns HTML, the proxy rule is not matching. Confirm nginx_internal.conf is mounted and that you reloaded the proxy container after editing it.

    Backend restarts in a loop. docker compose logs lemmy. Nearly always a malformed lemmy.hjson or a database it cannot reach. hjson is forgiving about quotes and commas but not about unbalanced braces.

    Password reset emails never arrive. Test the relay independently with swaks or your provider's console before blaming Lemmy. Confirm you are on 587 and not 25, since 25 is blocked.

    Images upload but do not display. Check the pict-rs volume ownership is 991:991 and that the API key in lemmy.hjson matches the one in the compose environment. A mismatch produces uploads that appear to succeed and then 403 on retrieval.

    Slow page loads under federation load. Check log_min_duration_statement output in the PostgreSQL logs. If autovacuum is falling behind, lower autovacuum_vacuum_scale_factor further. If shared_buffers is undersized for your RAM, raise it.

    OOM kills. Check dmesg -T | grep -i oom. Either your plan is undersized or pict-rs is processing something enormous. The memory limit in the compose file contains the latter.


    Wrapping up

    You now have a federating Lemmy instance on a RamNode VPS with TLS, tuned PostgreSQL, external SMTP, and tested backups.

    The operational reality of running a federated instance is different from running a forum. Your database absorbs traffic proportional to what you subscribe to, not what your users post. A single-user instance subscribed to fifty busy communities does more database work than a fifty-user instance subscribed to five. Size and monitor with that in mind.

    Before you invite anyone, do three things: confirm the ActivityPub content negotiation check passes, confirm a password reset email arrives, and confirm you can restore your database dump into a scratch database. Everything after that is moderation policy, which is a harder problem than any of this.