What the stack runs
The compose file the Lemmy docs publish defines six services. Only one of them, the bundled nginx called proxy, maps a port to the host; everything else talks over the compose network.
| Service | Job | State it keeps |
|---|---|---|
| lemmy | Backend: API, incoming and outgoing federation, scheduled tasks such as hot-rank updates | None on disk; reads lemmy.hjson |
| lemmy-ui | Server-side rendered frontend (expressjs) | Optional extra themes |
| pictrs | Uploads, thumbnails, cached remote images | ./volumes/pictrs |
| postgres | All posts, comments, votes, users, federation state | ./volumes/postgres |
| proxy | Internal nginx routing ActivityPub requests to the backend and browsers to the UI | None |
| postfix | Outgoing mail relay for sign-up and reset messages | Mail queue only |
The docs say Lemmy uses roughly 150 MB of RAM in the default Docker installation, with negligible CPU. What grows is Postgres and the pict-rs volume. With the default image_mode of StoreLinkPreviews, thumbnails for every linked page are stored for good; ProxyAllImages routes remote images through your server, which the docs say raises bandwidth use. Size the disks for those two directories, and give Postgres memory for its cache. The docs point to pgtune for sizing it, but the docs' compose file only mounts customPostgresql.conf at /etc/postgresql.conf and never loads it, so editing it alone changes nothing. Replace the example values with your own pgtune output (the downloaded file is sized for 8 GB and 4 CPUs, and keeps its own listen_addresses = '*' line), then add command: postgres -c config_file=/etc/postgresql.conf to the postgres service, as Lemmy's Ansible template does. The file's comments ask you to keep shared_buffers in step with the container's shared memory, so raise shm_size (1g in the docs' compose) to match. For one or two values, -c shared_buffers=VALUE style flags on the same command: line work too.
Which machine
Lemmy is an application with its database on the same box, which is the job the single-socket class on our dedicated server range is described for. A small instance fits a Moonshot cartridge, a one-service machine; pick one of the NVMe cartridge lines if you expect Postgres to see real write traffic. A busy instance with large communities and a large image cache belongs on a dual-socket machine, which the range lists for busy databases.
- Root, no neighbours. Nothing else runs on the hardware, so Postgres gets every byte of RAM you size it for.
- Unmetered port. No transfer allowance and no overage fee, which matters if you turn on
ProxyAllImagesor a popular community pulls traffic. - Console. Every racked single-, dual- and quad-socket line has a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC). Among cartridges only the m510 and m710x carry one; the rest stop at power control. With a console, a bad ufw rule is recoverable.
- IPv6. It is added at checkout at no charge; federating instances that reach you over AAAA need it working end to end.
- Delivery. A racked machine is handed over in about 30 minutes once payment is approved; Ubuntu is a free image on our Linux dedicated servers.
Install: firewall first, then the compose files
On a fresh Ubuntu machine, as root. The docs install docker.io and docker-compose-v2 from Ubuntu; check that the Server section of docker version shows Engine 28 or later, because only from Docker Engine 28 is a port published on 127.0.0.1 unreachable from other hosts on the same network segment (moby#45610). If it is older, stop there and install Docker Engine from the apt repository Docker maintains, following Docker's Ubuntu instructions (they remove docker.io and docker-compose-v2 first), before going on. IPV6 is set before the allow rules so ufw writes the IPv6 copies of them.
apt update && apt install -y docker.io docker-compose-v2 ufw nginx certbot python3-certbot-nginx
docker version
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw --force enable
mkdir -p /srv/lemmy && cd /srv/lemmy
wget https://raw.githubusercontent.com/LemmyNet/lemmy-docs/main/assets/docker-compose.yml
wget https://raw.githubusercontent.com/LemmyNet/lemmy-ansible/main/examples/config.hjson -O lemmy.hjson
wget https://raw.githubusercontent.com/LemmyNet/lemmy-ansible/main/templates/nginx_internal.conf
wget https://raw.githubusercontent.com/LemmyNet/lemmy-ansible/main/files/proxy_params
wget https://raw.githubusercontent.com/LemmyNet/lemmy-ansible/main/examples/customPostgresql.conf
wget https://raw.githubusercontent.com/LemmyNet/lemmy-ansible/main/templates/nginx.conf -O /etc/nginx/sites-available/lemmy
mkdir -p volumes/pictrs && chown -R 991:991 volumes/pictrs
- In
docker-compose.yml, the placeholders sit in double curly braces: put your hostname where it saysdomainand one long random string where it sayspostgres_password. The same string becomes the pict-rs API key, so it must matchapi_keyin lemmy.hjson. - Change the proxy's port line from
"10633:8536"to"127.0.0.1:10633:8536". A mapping with no address is published on every host address (0.0.0.0 and [::]), and Docker's published traffic skips ufw's rules on IPv4, and on IPv6 too when Docker's own IPv6 networking is on. - In
nginx_internal.conf, thenginx_internal_resolverplaceholder becomes127.0.0.11. - Write lemmy.hjson as below, then
chmod 600 docker-compose.yml. The backend container runs as an unprivileged user, so give it the config file:chown "$(docker compose run --rm -T --no-deps --entrypoint id lemmy -u)" lemmy.hjson && chmod 600 lemmy.hjson. - Bring the stack up (
docker compose up -d). The backend log,docker compose logs -f lemmy, says when it is listening.
lemmy.hjson and the first admin
The setup block is read only at first start, and Lemmy builds the admin account from it. Our reading, not the docs': because the compose port is bound to loopback and the host proxy is not live yet, that account exists before anyone outside can load the site. The password must be 10 to 60 characters and site_name at most 20; admin_email is optional. The database password, pict-rs key and admin password all sit in this file, which is why it stays at mode 600.
{
hostname: "forum.example.org"
database: {
host: postgres
password: "SAME_LONG_RANDOM_STRING"
}
pictrs: {
url: "http://pictrs:8080/"
api_key: "SAME_LONG_RANDOM_STRING"
}
email: {
smtp_server: "postfix:25"
smtp_from_address: "noreply@forum.example.org"
tls_type: "none"
}
setup: {
admin_username: "youradmin"
admin_password: "a passphrase of 10 to 60 characters"
site_name: "Example Forum"
}
}
Once the site is live, log in as that admin and set a new password on the settings page, so the copy in lemmy.hjson stops being the working one. The docs also advise appointing a second admin, so someone can act while you sleep.
TLS, the host proxy and federation
Federation needs tls_enabled: true, which is the default. Issue the certificate first with certbot certonly --nginx -d forum.example.org, because the template you downloaded points at the Let's Encrypt files. Then edit /etc/nginx/sites-available/lemmy: fill every domain placeholder (the cache zone name takes the domain with dots and hyphens turned into underscores), set the proxy_pass target to http://127.0.0.1:10633, and run mkdir -p /var/cache/lemmy. Link the file into sites-enabled, then nginx -t && systemctl reload nginx.
- Do not rewrite the Host header or any other signed header: the docs warn that it breaks federation signatures.
- Keep the clock synchronised. Activities are signed with a timestamp and discarded if it is off by more than an hour.
- Check the admin panel shows federation switched on, then test:
curl -H 'Accept: application/activity+json' https://forum.example.org/u/youradminmust return JSON, not HTML. - Choose a federation mode in the admin panel: open, a blocklist of instances you refuse, or an allowlist of the only ones you talk to.
For mail, the bundled postfix sends directly from your IP. To relay through a mail provider instead, set smtp_server, smtp_login, smtp_password and tls_type (starttls or tls) in lemmy.hjson. Test it the way the docs suggest: log out and request a password reset.
Backups and upgrades
The docs back up two things: a full Postgres dump and the volumes folder. Dump the database rather than copying volumes/postgres while it runs, and archive volumes/pictrs for the images. Create the target first with install -d -m 700 /var/backups/lemmy, then put this in /etc/cron.d/lemmy-backup:
15 3 * * * root umask 077; cd /srv/lemmy && docker compose exec -T postgres pg_dumpall -c -U lemmy | gzip > /var/backups/lemmy/db-$(date +\%F).sql.gz
45 3 * * * root umask 077; cd /srv/lemmy && docker compose stop pictrs && tar -C /srv/lemmy/volumes -czf /var/backups/lemmy/pictrs-$(date +\%F).tar.gz pictrs; docker compose start pictrs
-T matters: cron has no terminal. pict-rs is stopped for its copy because it keeps its metadata in a sled database, and the pict-rs README recommends copying that while pict-rs is not running; it is back as soon as the archive is written. Copy both files off the machine yourself and prune old ones. Rehearse restores on a scratch machine or a fresh copy of the stack, never on the live one. Run docker compose up -d postgres. If the lemmy service has already started against that database, the docs say to clear it first by dropping and recreating the public schema: docker compose exec -T postgres psql -U lemmy -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;". Then load the dump with gunzip < db-DATE.sql.gz | docker compose exec -T postgres psql -U lemmy and start the rest.
Upgrades are a tag change. Edit the image versions in docker-compose.yml (for example dessalines/lemmy:0.19.4 to 0.19.5, and lemmy-ui with it), read the release announcement for extra steps, take a fresh dump, then docker compose pull && docker compose up -d. Never change the domain after you have federated: the docs warn it breaks federation.
What the operator answers for
The Lemmy docs are plain about this: admins are responsible for the entire instance and can act on any content, and each instance creates and enforces its own moderation policy, because there is no platform-wide team. Running one means publishing instance rules, reading the report queue, and using the tools the docs list: remove, lock, ban from a community or the site, purge a user or post with their media. The mod log keeps those actions visible.
- Require registration applications or a captcha; the docs ask instances listed on join-lemmy.org to require applications, to keep bots and trolls off the fediverse.
- Block or allowlist instances whose rules conflict with yours.
- Expect takedown notices: the docs describe them arriving through the hosting provider or domain registrar, and the site going down if the content stays up.
This is not legal advice. What the law asks of an instance operator depends on where you and your users are; take that question to a lawyer. Lemmy itself is licensed AGPL-3.0, whose section 13 asks you to offer users the source of a modified version you run for them over a network.
Running other fediverse services too? The same machine habits apply to a Mastodon server and a Matrix homeserver.
Questions
How much RAM does a Lemmy instance need?
The docs say Lemmy uses roughly 150 MB of RAM in the default Docker installation, with negligible CPU. That is a baseline for a quiet instance; as it grows, Postgres benefits most from extra memory, so size the machine around the database and pass pgtune's values to it as command flags.
Docker or Ansible?
The docs recommend Ansible because installing and updating are simpler; Docker Compose by hand gives you the same containers and full control of each file. Both leave a volumes folder with the database and images, so backups work the same way.
Can I change my instance's domain later?
Only before you federate. The docs give SQL to rewrite the domain in the database, and warn that doing it after federating breaks federation. Choose the hostname before the first start.
Why do my follows stay at Subscription Pending?
Usually a proxy or clock problem. Check that your reverse proxy passes ActivityPub requests to the backend and leaves the Host header alone, and that the server clock is correct, because a timestamp skewed past one hour gets an activity rejected. The JSON test with an activity+json Accept header shows whether other instances can read your objects.
Do I have to publish my source code?
Lemmy is AGPL-3.0. Section 13 of that licence covers modified versions offered to users over a network. This page does not give legal advice; read the licence and ask a lawyer if you change the code.