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 type | Recommended specs |
|---|---|
| Single-user or small private instance, few subscriptions | 4 GB RAM, 2 vCPU, 50 GB NVMe |
| Small public instance, tens of active users, moderate federation | 8 GB RAM, 4 vCPU, 100 GB NVMe |
| Larger public instance, heavy subscription list | 16 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:
lemmy.example.com. A 198.51.100.42Add an AAAA record if RamNode has assigned you IPv6:
lemmy.example.com. AAAA 2001:db8::1Set 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:
dig +short lemmy.example.com A
dig +short lemmy.example.com AAAAEmail 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:
apt update && apt upgrade -y
apt install -y curl ca-certificates gnupg ufw fail2ban unattended-upgrades postgresql-client-16 jqSet the hostname
hostnamectl set-hostname lemmy.example.comAdd it to /etc/hosts:
echo "127.0.1.1 lemmy.example.com lemmy" >> /etc/hostsCreate an admin user and lock down SSH
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deployEdit /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yesReload:
systemctl reload sshAdd swap
Federation backfill produces memory spikes. Swap is your insurance against the OOM killer taking out PostgreSQL mid-transaction.
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstabLower the swappiness so it stays a safety net rather than a performance drag:
echo 'vm.swappiness=10' >> /etc/sysctl.d/99-lemmy.conf
sysctl --systemFirewall
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableNothing else needs to be exposed. Every service in the stack binds to the Docker network or to localhost.
Automatic security updates
dpkg-reconfigure --priority=low unattended-upgradesStep 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.
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-pluginAdd your deploy user to the docker group:
usermod -aG docker deployVerify:
docker --version
docker compose versionCap the log growth
Docker's default json-file driver will happily fill your disk. Create /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}systemctl restart dockerStep 3: Lay out the stack
Log in as deploy from here on.
mkdir -p ~/lemmy/volumes/{postgres,pictrs,lemmy-ui}
cd ~/lemmypict-rs runs as UID/GID 991 inside its container and will not create its own directory permissions:
sudo chown -R 991:991 ~/lemmy/volumes/pictrsGenerate your secrets now and keep them somewhere safe:
openssl rand -hex 32 # postgres password
openssl rand -hex 32 # pict-rs API key
openssl rand -base64 24 # admin passwordStep 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.
# 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.
{
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:
chmod 600 ~/lemmy/lemmy.hjsonThe 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+jsonorapplication/ld+jsongo to the backend. - All POST requests go to the backend.
/api,/pictrs,/feeds,/nodeinfo,/.well-known/webfinger,/.well-known/nodeinfogo to the backend.- Everything else goes to the UI.
Create ~/lemmy/nginx_internal.conf:
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.
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-loggingTwo 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:
cd ~/lemmy
docker compose up -d
docker compose logs -f lemmyWatch 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:
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.
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 caddyReplace /etc/caddy/Caddyfile:
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
}
}
}sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyCaddy will fetch a Let's Encrypt certificate on first request. Confirm:
curl -sI https://lemmy.example.com | head -1Open 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
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
curl -s "https://lemmy.example.com/.well-known/webfinger?resource=acct:admin@lemmy.example.com" | jqCheck nodeinfo
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:
docker compose logs lemmy | grep -i federatThe 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:
#!/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"chmod +x /home/deploy/lemmy-backup.shSchedule it:
crontab -e15 3 * * * /home/deploy/lemmy-backup.sh >> /home/deploy/backup.log 2>&1Push 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.
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 -dStep 12: Maintenance
Upgrading
Read the release notes. Lemmy point releases sometimes carry database migrations that cannot be rolled back.
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 lemmyKeep lemmy and lemmy-ui on matching versions. Mismatched versions produce API errors that look like frontend bugs.
Watch the database size
docker compose exec postgres psql -U lemmy -d lemmy -c \
"SELECT pg_size_pretty(pg_database_size('lemmy'));"And the biggest tables:
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:
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
du -sh ~/lemmy/volumes/pictrsIf 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.
