Can I self-host ChatGPT?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called LibreChat. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $20 a month you stop paying ChatGPT — $240 a year on the Plus plan.

Why people pay for ChatGPT

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Twenty dollars buys a flat rate on a meter that would otherwise be running, and that is most of the product. The subscription bundles the models themselves, voice, image generation, browsing and file analysis into one price, on apps that already exist for every phone in the house, and it means nobody has to think about tokens on a Tuesday afternoon. Self-hosting a chat interface replaces the window, not the models: the meter comes back, and it is now yours to watch.

ChatGPT plans and list prices
PlanList priceWhat it buys
FreefreeLimited message and upload allowances. OpenAI began testing advertising on the free tier in 2026.
Go$8/moUS price for the lower-cost consumer tier, which OpenAI took worldwide in January 2026. Also carries ads.
Plusthe plan this page prices against$20/moThe tier most personal subscribers are on, and the one this page prices against.
Pro$200/moOpenAI sells two Pro tiers; the $100 one carries lower usage allowances than the $200 one recorded here.
Business$25/mo per seatPer user per month billed monthly, $20 per user per month billed annually, with a two-seat minimum. Adds a contractual commitment not to train on the team's conversations.
Enterprisequote onlyQuote only. OpenAI publishes no price.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by LibreChat

One project, named before the prompt, so you know what you are about to install.

One chat window in front of Anthropic, OpenAI and Google, with the conversation history in a database you own.

The only one of these that reads like the thing people are actually cancelling: one window, several providers side by side in the same conversation list, search across everything you have ever asked, and accounts with real password login rather than a shared box on your desk. It is MIT with no branding clause and no commercial carve-out, which matters when the whole point is that the software is yours. What it does not do is give you a model. You supply the provider key, the tokens are metered, and on a heavy month that meter can beat the twenty dollars you were paying.

The swap

You're paying

ChatGPT

$20/mo · $240/yr

is replaced by

You'd run

LibreChat

ONE EVENING · ~90 min to running · 2048 MB RAM

ChatGPT Plus · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same LibreChat: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

342 lines · 14,649 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install LibreChat v0.8.7 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say this to the user first. LibreChat is a chat interface, not a model. It answers with
whatever provider key it is given, and that key is metered per token by Anthropic, OpenAI or
Google and billed to the user. There is no subscription and no flat monthly ceiling.

LibreChat with MongoDB and Meilisearch needs 2048 MB of RAM available and 10 GB free on /srv.
All three images publish amd64 and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope: the app image alone unpacks to over a gigabyte. If `dig +short`
prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/librechat /srv/librechat/backups
sudo install -d -m 700 /srv/librechat/mongo /srv/librechat/meili
sudo install -d -m 750 -o 1000 -g 1000 /srv/librechat/images /srv/librechat/uploads /srv/librechat/logs
ls -la /srv/librechat
```

Assert: `ls -la` shows `backups` owned by the login user, `mongo` and `meili` at mode `700`
owned by root, and `images`, `uploads` and `logs` owned by uid `1000`. Three owners on purpose:
the mongo image chowns its data directory on first start, Meilisearch runs as root in its
container, and the LibreChat image runs as `node`, which is uid 1000.

## 3. Secrets

Five secrets, all generated here, none printed. Do not repeat them in your summary or in a log
line. `CREDS_KEY` is a 32-byte key and `CREDS_IV` a 16-byte initialisation vector, both
hex; upstream documents that the app crashes on start-up without them. They encrypt every
provider key typed into the browser later.

```bash
umask 077
cat > /srv/librechat/.env <<EOF
DOMAIN_CLIENT=https://<DOMAIN>
DOMAIN_SERVER=https://<DOMAIN>
ALLOW_REGISTRATION=true
CREDS_KEY=$(openssl rand -hex 32)
CREDS_IV=$(openssl rand -hex 16)
JWT_SECRET=$(openssl rand -hex 32)
JWT_REFRESH_SECRET=$(openssl rand -hex 32)
MEILI_MASTER_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/librechat/.env
umask 022
ls -l /srv/librechat/.env
```

Assert: the file exists with mode `-rw-------`, with `<DOMAIN>` on the first two lines replaced
by the real hostname. `ALLOW_REGISTRATION` is true only until step 7 closes it. None of these
five is a provider key: the user supplies those in the browser.

## 4. compose.yml

```bash
cat > /srv/librechat/compose.yml <<'EOF'
# LibreChat · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://www.librechat.ai/docs/local/docker
#   variable reference . https://www.librechat.ai/docs/configuration/dotenv
#   reverse proxy ...... https://www.librechat.ai/docs/remote/nginx
#
# Three services: the app, the MongoDB holding accounts and conversations, and
# the Meilisearch that makes those conversations searchable. Upstream's compose
# file adds a RAG API and a pgvector database for chatting with uploaded
# documents; both are left out, because they need an embeddings API key of
# their own and would make this a five-container stack.
#
# MongoDB runs with --noauth, as upstream ships it. That is safe here only
# because it publishes no port: the app container on this private network is
# the one thing that can reach 27017. Digests read 2026-08-06, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongodb:
    image: mongo:8.0.20@sha256:098862b1339f031900ca66cf8fef799e616d6324fa41b9a263f2ec899552c1ef
    restart: unless-stopped
    command: ["mongod", "--noauth"]
    volumes:
      - /srv/librechat/mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 10s
      retries: 24
    # No `ports:`: 27017 never leaves the compose network.

  meilisearch:
    image: getmeili/meilisearch:v1.35.1@sha256:8b57fc3c7f46535ddef3828df1538465ac19d892eb57c9a10da6df0880bd5856
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - /srv/librechat/meili:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7700/health"]
      interval: 10s
      retries: 24
    # No `ports:` either: only the app queries this index.

  api:
    image: ghcr.io/danny-avila/librechat:v0.8.7@sha256:c5db3331b845e1f289f8d04c0c77936c4bbe372f76730a804abc1c37e44d23a9
    restart: unless-stopped
    env_file: /srv/librechat/.env
    environment:
      HOST: 0.0.0.0
      MONGO_URI: mongodb://mongodb:27017/LibreChat
      MEILI_HOST: http://meilisearch:7700
      SEARCH: "true"
      # Caddy terminates TLS and is the only hop in front of this container.
      TRUST_PROXY: "1"
      # No mail server here, so the flow that needs one is off.
      ALLOW_PASSWORD_RESET: "false"
      # Keep this out of search engine results.
      NO_INDEX: "true"
      # `user_provided`: no credential is stored here. LibreChat asks each
      # signed-in person for theirs in the browser and encrypts it with
      # CREDS_KEY.
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-user_provided}
      OPENAI_API_KEY: ${OPENAI_API_KEY:-user_provided}
      GOOGLE_KEY: ${GOOGLE_KEY:-user_provided}
    volumes:
      - /srv/librechat/images:/app/client/public/images
      - /srv/librechat/uploads:/app/uploads
      - /srv/librechat/logs:/app/logs
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8112.
      - "127.0.0.1:8112:3080"
    depends_on:
      mongodb:
        condition: service_healthy
      meilisearch:
        condition: service_healthy
EOF
cd /srv/librechat && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The app reads the five secrets through `env_file` and
Meilisearch reads the master key by substitution from the same file, so both agree on it
without it appearing here.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-librechat
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LibreChat · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.librechat.ai/docs/remote/nginx and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also DOMAIN_CLIENT and DOMAIN_SERVER in .env; change it in one place and the
# login cookie stops matching the address the browser is on.

<DOMAIN> {
	# Model replies arrive as a stream of events, one piece at a time.
	# Nothing here compresses or holds that stream: hence no `encode`
	# line, and a proxy that flushes every write.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8112 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8112 {
		flush_interval -1
	}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-librechat, reload, and report what it objected to. Caddy requests
the certificate on first request and renews it on its own. Nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8112 is bound to 127.0.0.1, and 27017 and 7700 are never published at all: an
unauthenticated MongoDB reachable from the internet is how self-hosted installs end up in a
breach list. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and nothing for 8112, 27017 or 7700.

## 7. Start and verify

The first pull downloads more than a gigabyte, and the app takes about a minute after that
before it answers.

```bash
cd /srv/librechat
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/api/config
```

Assert all three and print what you received for each: the loop ends on `200`, `/health`
answers the literal string `OK`, and `/api/config` returns JSON containing
`"appTitle":"LibreChat"` and `"registrationEnabled":true`. If any misses, stop, run
`docker compose logs --tail 40 api` and `docker compose logs --tail 20 mongodb`, and name the
likely cause: a mongodb container that never reports healthy points at step 2, and a `502`
means the app is still starting. A running container is not success.

The first screen at https://<DOMAIN> shows the heading `Welcome back` over `Email` and
`Password` fields, a `Sign in` button, and below it `Don't have an account?` with a `Sign up`
link.

STOP: tell the user to open https://<DOMAIN>, click `Sign up`, create their account, and wait.
Do not continue until they confirm. Upstream documents that the first account registered becomes
the admin account and that there are no default credentials. With no mail server here, that
account is the whole recovery story.

Once they confirm, close registration and restart the app:

```bash
sed -i 's/^ALLOW_REGISTRATION=true$/ALLOW_REGISTRATION=false/' /srv/librechat/.env
cd /srv/librechat && docker compose up -d --force-recreate api
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/config
```

Assert: the loop ends on `200` and the config JSON now reads `"registrationEnabled":false`, and
the user reloads https://<DOMAIN> in a private window and confirms the `Sign up` link is gone.
Both must pass before you go on.

STOP: tell the user to sign in, pick a provider from the model menu, paste their own provider
key into the dialog LibreChat shows, send one message, and wait. Do not report success until
they confirm an answer streamed back. That credential is theirs and billed to their account;
never ask them to paste it to you.

## 8. First backup and restore

Two artifacts. The MongoDB dump holds the accounts, the conversations and the encrypted
provider keys. The config archive holds what rebuilds the service around them, `.env` included:
without `CREDS_KEY` every stored key in the dump is unreadable. The Meilisearch directory is
not backed up, because it is an index rebuilt from MongoDB.

```bash
cd /srv/librechat
docker compose exec -T mongodb mongodump --archive --gzip --db=LibreChat > /srv/librechat/backups/librechat-db-$(date +%F).archive.gz
sudo tar -czf /srv/librechat/backups/librechat-config-$(date +%F).tar.gz -C /srv/librechat compose.yml .env images uploads -C /etc/caddy Caddyfile
ls -lh /srv/librechat/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped,
`mongodump` reads a running database. A backup on the same disk is not a backup, so run this
from the user's machine:

```bash
mkdir -p ~/backups/librechat
scp vps:/srv/librechat/backups/* ~/backups/librechat/
```

To restore: `docker compose down`, untar the config archive back into /srv/librechat so `.env`
is in place first, `docker compose up -d mongodb`, wait for it to report healthy, then feed the
dump in with
`docker compose exec -T mongodb mongorestore --archive --gzip --drop < backups/librechat-db-$(date +%F).archive.gz`,
then `docker compose up -d`. Tell the user that is the whole disaster plan, and that a dump restored
without its matching `.env` comes back unreadable.

## 9. Updating later

New versions are listed at https://github.com/danny-avila/LibreChat/releases. Upstream marks
every release as a prerelease there, so release candidates sit in the same list: skip any tag
with `-rc` in it. Take both backups first, then edit the `image:` line in
/srv/librechat/compose.yml to the new tag and digest:

```bash
cd /srv/librechat
docker compose pull
docker compose up -d
docker compose logs --tail 30 api
```

Leave the mongo and Meilisearch tags alone unless a release note says otherwise. Meilisearch
refuses to open a database written by another version and wants a dump and reimport; MongoDB
major versions want their own upgrade path. Re-run step 7's health check before calling it
done.

## 10. What will probably go wrong

The install will look finished and broken at the same time. I signed in, typed a message, and
got a red error with nothing useful in it, because this server holds no provider credential and
I had not given it one. Nothing was wrong: `user_provided` means LibreChat waits for one from
the browser, and until it arrives there is no model on the other end. Give it a key before you
conclude anything.

## 11. Out of scope

- Do not configure SMTP. Password reset is off and nothing here sends mail, so the first
  account is the whole recovery story.
- Do not add the RAG API or the pgvector database from upstream's compose file. Chatting with
  uploaded documents needs an embeddings credential billed separately, and two more containers.
- Do not run the bundled admin panel. It is a second web application on its own port with its
  own session secret, and this prompt installs the chat server.
- Do not write a librechat.yaml. The three provider endpoints are set in compose.yml.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing LibreChat v0.8.7 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. LibreChat is a chat interface, not a model. It answers with whatever
provider key you give it, and that key is metered per token by Anthropic, OpenAI or Google and
billed to you. There is no subscription and no flat monthly ceiling: light use usually costs
less than a subscription, heavy use can cost more, and the meter is now yours to watch.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that does
not resolve and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM
is the other common stop: three containers plus a Node application that builds its client
indexes at start-up will be killed by the kernel on a 1 GB box, and the failure reads like a
broken image rather than a small server. The three images together unpack to several gigabytes,
which is what most of the 10 GB floor is for.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/librechat /srv/librechat/backups
sudo install -d -m 700 /srv/librechat/mongo /srv/librechat/meili
sudo install -d -m 750 -o 1000 -g 1000 /srv/librechat/images /srv/librechat/uploads /srv/librechat/logs
ls -la /srv/librechat
```

You should see: `backups` owned by you, `mongo` and `meili` at `drwx------` owned by root, and
`images`, `uploads` and `logs` owned by `1000`.

If you do not: leave `mongo` owned by root on purpose. The mongo image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. The three at uid 1000 are the opposite case: the LibreChat image runs as
its `node` user, which is uid 1000, and a directory it cannot write is a container that exits
while you are still reading the log.

## 3. Secrets

Five secrets, all generated here, on the server, and all written straight into a file only you
can read. `CREDS_KEY` is a 32-byte key and `CREDS_IV` a 16-byte initialisation vector, both in
hex, and upstream documents that the app crashes on start-up without them. They are what
encrypts the provider keys you type into the browser later.

```bash
umask 077
cat > /srv/librechat/.env <<EOF
DOMAIN_CLIENT=https://<DOMAIN>
DOMAIN_SERVER=https://<DOMAIN>
ALLOW_REGISTRATION=true
CREDS_KEY=$(openssl rand -hex 32)
CREDS_IV=$(openssl rand -hex 16)
JWT_SECRET=$(openssl rand -hex 32)
JWT_REFRESH_SECRET=$(openssl rand -hex 32)
MEILI_MASTER_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/librechat/.env
umask 022
ls -l /srv/librechat/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first two lines with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/librechat/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all five, which is fine before anyone has signed in and a problem afterwards: a changed
`CREDS_KEY` leaves every stored provider key in the database undecryptable, and a changed
`JWT_SECRET` signs everyone out.

Do not paste that file, any of those five values, or any command output containing them into
this chat window. None of them is a provider API key: those you enter in the browser in step 7,
and they should not come near this window either.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/librechat/compose.yml <<'EOF'
# LibreChat · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://www.librechat.ai/docs/local/docker
#   variable reference . https://www.librechat.ai/docs/configuration/dotenv
#   reverse proxy ...... https://www.librechat.ai/docs/remote/nginx
#
# Three services: the app, the MongoDB holding accounts and conversations, and
# the Meilisearch that makes those conversations searchable. Upstream's compose
# file adds a RAG API and a pgvector database for chatting with uploaded
# documents; both are left out, because they need an embeddings API key of
# their own and would make this a five-container stack.
#
# MongoDB runs with --noauth, as upstream ships it. That is safe here only
# because it publishes no port: the app container on this private network is
# the one thing that can reach 27017. Digests read 2026-08-06, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongodb:
    image: mongo:8.0.20@sha256:098862b1339f031900ca66cf8fef799e616d6324fa41b9a263f2ec899552c1ef
    restart: unless-stopped
    command: ["mongod", "--noauth"]
    volumes:
      - /srv/librechat/mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 10s
      retries: 24
    # No `ports:`: 27017 never leaves the compose network.

  meilisearch:
    image: getmeili/meilisearch:v1.35.1@sha256:8b57fc3c7f46535ddef3828df1538465ac19d892eb57c9a10da6df0880bd5856
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - /srv/librechat/meili:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7700/health"]
      interval: 10s
      retries: 24
    # No `ports:` either: only the app queries this index.

  api:
    image: ghcr.io/danny-avila/librechat:v0.8.7@sha256:c5db3331b845e1f289f8d04c0c77936c4bbe372f76730a804abc1c37e44d23a9
    restart: unless-stopped
    env_file: /srv/librechat/.env
    environment:
      HOST: 0.0.0.0
      MONGO_URI: mongodb://mongodb:27017/LibreChat
      MEILI_HOST: http://meilisearch:7700
      SEARCH: "true"
      # Caddy terminates TLS and is the only hop in front of this container.
      TRUST_PROXY: "1"
      # No mail server here, so the flow that needs one is off.
      ALLOW_PASSWORD_RESET: "false"
      # Keep this out of search engine results.
      NO_INDEX: "true"
      # `user_provided`: no credential is stored here. LibreChat asks each
      # signed-in person for theirs in the browser and encrypts it with
      # CREDS_KEY.
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-user_provided}
      OPENAI_API_KEY: ${OPENAI_API_KEY:-user_provided}
      GOOGLE_KEY: ${GOOGLE_KEY:-user_provided}
    volumes:
      - /srv/librechat/images:/app/client/public/images
      - /srv/librechat/uploads:/app/uploads
      - /srv/librechat/logs:/app/logs
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8112.
      - "127.0.0.1:8112:3080"
    depends_on:
      mongodb:
        condition: service_healthy
      meilisearch:
        condition: service_healthy
EOF
cd /srv/librechat && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/librechat/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/librechat/compose.yml` and paste again in one go. The three `${...:-user_provided}`
lines are not a mistake. `user_provided` tells LibreChat to hold no provider credential at all
and ask each signed-in person for their own in the browser, encrypted with `CREDS_KEY`. If you
would rather run one key for everyone, put the real value in `.env` under the same name and it
wins over the default.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-librechat
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LibreChat · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.librechat.ai/docs/remote/nginx and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also DOMAIN_CLIENT and DOMAIN_SERVER in .env; change it in one place and the
# login cookie stops matching the address the browser is on.

<DOMAIN> {
	# Model replies arrive as a stream of events, one piece at a time.
	# Nothing here compresses or holds that stream: hence no `encode`
	# line, and a proxy that flushes every write.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8112 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8112 {
		flush_interval -1
	}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-librechat /etc/caddy/Caddyfile`, reload,
and paste again. There is no `encode` line in that block and there is a `flush_interval -1`,
both on purpose: a model reply arrives as a stream of events, and a proxy that buffers it turns
a live answer into a long pause followed by a wall of text.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8112`, `27017` or `7700`.

If you do not: delete anything for those three with `sudo ufw delete allow 8112`. 8112 is bound
to 127.0.0.1 by the compose file, and 27017 and 7700 are never published, so neither database
has a host port a firewall rule could apply to. That matters more here than usual: this MongoDB
runs without authentication, exactly as upstream ships it, and the only thing keeping that
sensible is that nothing outside the compose network can reach it. `Status: inactive` is a
different problem: Prompt Zero left this firewall enabled, so something has turned it off since,
and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

The first pull downloads more than a gigabyte, and the app takes about a minute after that
before it answers.

```bash
cd /srv/librechat
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/api/config
```

You should see, in order: the loop reaching `200`, the literal string `OK`, then a JSON object
containing `"appTitle":"LibreChat"` and `"registrationEnabled":true`.

If you do not: run `docker compose logs --tail 20 mongodb` first, because a database that never
reports healthy holds the app back and that is step 2 done wrong, then
`docker compose logs --tail 40 api`. A `502` from Caddy while the loop is still counting is
normal for the first minute; a `502` that never clears means the app container is not listening
on 3080. A running container is not success.

Now open https://<DOMAIN> in a browser. The first screen shows the heading `Welcome back` over
`Email` and `Password` fields, a `Sign in` button, and below it `Don't have an account?` with a
`Sign up` link. Click `Sign up` and create your account. Upstream documents that the first
account you register becomes the admin account and that there are no default credentials, so
this account is the whole install: nothing here sends mail, and there is no reset link behind
it. Put the password in your password manager now.

Then close registration, so the login page stops being a signup page:

```bash
sed -i 's/^ALLOW_REGISTRATION=true$/ALLOW_REGISTRATION=false/' /srv/librechat/.env
cd /srv/librechat && docker compose up -d --force-recreate api
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/config
```

You should see: the loop reaching `200` again, and `"registrationEnabled":false` in the JSON.
Reload https://<DOMAIN> in a private window and confirm the `Sign up` link is gone.

If you do not: `"registrationEnabled":true` still there means the `sed` did not match, usually
because the line had trailing whitespace. Check with `grep ALLOW_REGISTRATION /srv/librechat/.env`
and edit it by hand, then run the recreate again. Leaving this open on a public hostname means
anyone who finds the address can make themselves an account on your instance.

Last, sign in, pick a provider from the model menu, paste your own provider API key into the
dialog LibreChat shows, and send one message. You should see an answer stream back a few words
at a time. That key is yours and billed to your account by the provider, it belongs in the
browser dialog and nowhere else, and it must not be pasted into this chat window.

## 8. First backup and restore

Two artifacts. The MongoDB dump holds the accounts, the conversations and the encrypted
provider keys. The config archive holds what rebuilds the service around them, `.env` included:
without `CREDS_KEY` every stored key in the dump is unreadable. The Meilisearch directory is
not backed up, because it is an index rebuilt from MongoDB.

```bash
cd /srv/librechat
docker compose exec -T mongodb mongodump --archive --gzip --db=LibreChat > /srv/librechat/backups/librechat-db-$(date +%F).archive.gz
sudo tar -czf /srv/librechat/backups/librechat-config-$(date +%F).tar.gz -C /srv/librechat compose.yml .env images uploads -C /etc/caddy Caddyfile
ls -lh /srv/librechat/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline;
`mongodump` reads a running database.

If you do not: an archive of about 20 bytes is an empty dump, which means `mongodump` failed and
the shell created the file anyway. It writes its error to stderr, so run the line again and read
what came back before the prompt returned.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/librechat
scp vps:/srv/librechat/backups/* ~/backups/librechat/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/librechat/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one test conversation:

```bash
cd /srv/librechat
docker compose down
sudo rm -rf /srv/librechat/mongo
sudo install -d -m 700 /srv/librechat/mongo
docker compose up -d mongodb
sleep 30
docker compose exec -T mongodb mongorestore --archive --gzip --drop < /srv/librechat/backups/librechat-db-$(date +%F).archive.gz
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/health
```

You should see: `restoring` and `finished restoring` lines from mongorestore, then `OK` from the
last command. Sign in with the same password and open the conversation you sent in step 7.

If you do not: `Failed: no reachable servers` means the database container had not finished
starting, so wait longer and run the `mongorestore` line again. If you can reach the login page but
your password no longer works, the `.env` you restored is not the one the dump was taken with,
and that is the failure this step exists to find while it is still cheap.

## 9. Updating later

New versions are listed at https://github.com/danny-avila/LibreChat/releases. Upstream marks
every release as a prerelease there, so release candidates sit in the same list: skip any tag
with `-rc` in it. Take both backup artifacts first, then edit the `image:` line in
/srv/librechat/compose.yml to the new tag and its digest.

```bash
cd /srv/librechat
docker compose pull
docker compose up -d
docker compose logs --tail 30 api
```

You should see: the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Leave the mongo
and Meilisearch tags alone unless a release note says otherwise. Meilisearch refuses to open a
database written by another version and wants a dump and reimport, and MongoDB major versions
want their own upgrade path, so moving either of those is a separate evening.

## 10. What will probably go wrong

The install will look finished and broken at the same time. I signed in, typed a message, and
got a red error with nothing useful in it, because this server holds no provider credential and
I had not given it one. Nothing was wrong: `user_provided` means LibreChat waits for one from
the browser, and until it arrives there is no model on the other end. Give it a key before you
conclude anything.

## 11. Out of scope

- Do not configure SMTP. Password reset is off and nothing here sends mail, so the first
  account is the whole recovery story.
- Do not add the RAG API or the pgvector database from upstream's compose file. Chatting with
  uploaded documents needs an embeddings credential billed separately, and two more containers.
- Do not run the bundled admin panel. It is a second web application on its own port with its
  own session secret, and this install gives you the chat server.
- Do not write a librechat.yaml. The three provider endpoints are set in compose.yml.

337 lines · 14,999 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install LibreChat v0.8.7, with the MongoDB and Meilisearch it needs, under ~/selfhost/librechat,
answering at http://localhost:8112.

## 1. Preflight

Tell the user both of these before step 2. LibreChat is a chat interface, not a model: it
answers with whatever provider key it is given, metered per token by Anthropic, OpenAI or
Google and billed to them, so light use usually costs less than a subscription and heavy use
can cost more. And it answers at http://localhost:8112, this computer and nowhere else: not
their phone, and not this machine while asleep.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. These three containers need 2048 MB of RAM
available and 10 GB free on the home disk; all three images publish amd64 and arm64. On macOS
and Windows that figure is the host's, and Docker Desktop's virtual machine takes its share. If RAM is under 2048 MB or disk under 10 GB, print both and stop. Do not install and
hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo 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/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/librechat/{images,uploads,logs,meili,backups}
if [ "$(uname -s)" = "Linux" ]; then sudo chown -R 1000:1000 ~/selfhost/librechat/{images,uploads,logs}; fi
ls -la ~/selfhost/librechat
```

Assert: `ls -la` shows all five directories. The app image runs as its `node` user, uid 1000,
so on Linux three of them are chowned to that uid; on macOS and Windows Docker Desktop handles
it and the fence does nothing. There is no folder for the database: step 5 keeps it in a
volume Docker manages.

## 4. Secrets

Five secrets, all generated here, none printed. Keep them out of your summary and out of any
log line. `CREDS_KEY` is a 32-byte key and `CREDS_IV` a 16-byte initialisation vector, both
hex; upstream documents that the app crashes without them, and they encrypt every provider key
typed in later.

```bash
umask 077
cat > ~/selfhost/librechat/.env <<EOF
DOMAIN_CLIENT=http://localhost:8112
DOMAIN_SERVER=http://localhost:8112
ALLOW_REGISTRATION=true
CREDS_KEY=$(openssl rand -hex 32)
CREDS_IV=$(openssl rand -hex 16)
JWT_SECRET=$(openssl rand -hex 32)
JWT_REFRESH_SECRET=$(openssl rand -hex 32)
MEILI_MASTER_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/librechat/.env
umask 022
ls -l ~/selfhost/librechat/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. On Windows the mode bits are advisory and the real boundary is the
user's own account. `ALLOW_REGISTRATION` is true only until step 7 closes it.

## 5. compose.yml

```bash
cat > ~/selfhost/librechat/compose.yml <<'EOF'
# LibreChat · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://www.librechat.ai/docs/local/docker
#   variable reference . https://www.librechat.ai/docs/configuration/dotenv
#
# Three services. Paths are relative to ~/selfhost/librechat/, so one file works
# on macOS, Linux and Windows. Upstream's RAG API and pgvector database are left
# out: they need an embeddings API key of their own. MongoDB sits in a named
# volume, because the image chowns /data/db to a uid Docker Desktop cannot grant
# on Windows, and runs --noauth as upstream ships it, reachable only from the
# app container. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongodb:
    image: mongo:8.0.20@sha256:098862b1339f031900ca66cf8fef799e616d6324fa41b9a263f2ec899552c1ef
    restart: unless-stopped
    command: ["mongod", "--noauth"]
    volumes:
      - librechat-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 10s
      retries: 24

  meilisearch:
    image: getmeili/meilisearch:v1.35.1@sha256:8b57fc3c7f46535ddef3828df1538465ac19d892eb57c9a10da6df0880bd5856
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - ./meili:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7700/health"]
      interval: 10s
      retries: 24
    # No `ports:`: only the app container queries this index.

  api:
    image: ghcr.io/danny-avila/librechat:v0.8.7@sha256:c5db3331b845e1f289f8d04c0c77936c4bbe372f76730a804abc1c37e44d23a9
    restart: unless-stopped
    env_file: ./.env
    environment:
      HOST: 0.0.0.0
      MONGO_URI: mongodb://mongodb:27017/LibreChat
      MEILI_HOST: http://meilisearch:7700
      SEARCH: "true"
      # No TLS here; browsers trust http://localhost anyway.
      SESSION_COOKIE_SECURE: "false"
      # No mail server, so the flow that needs one is off.
      ALLOW_PASSWORD_RESET: "false"
      # `user_provided`: no credential is stored here. It is asked for in
      # the browser and encrypted with CREDS_KEY.
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-user_provided}
      OPENAI_API_KEY: ${OPENAI_API_KEY:-user_provided}
      GOOGLE_KEY: ${GOOGLE_KEY:-user_provided}
    volumes:
      - ./images:/app/client/public/images
      - ./uploads:/app/uploads
      - ./logs:/app/logs
    ports:
      # Loopback only: no other device on the wifi can reach 8112.
      - "127.0.0.1:8112:3080"
    depends_on:
      mongodb:
        condition: service_healthy
      meilisearch:
        condition: service_healthy

volumes:
  librechat-mongo:
EOF
cd ~/selfhost/librechat && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port, one volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so the login cookie still works.
- No firewall rule. Nothing is published past loopback.

8112 is bound to 127.0.0.1, this computer only, and the other two publish no host port at all,
which is what makes running MongoDB unauthenticated reasonable. Confirm it:

```bash
grep -c '127.0.0.1:8112:3080' ~/selfhost/librechat/compose.yml
grep -c '27017:\|7700:' ~/selfhost/librechat/compose.yml
```

Assert: `1`, then `0`.

## 7. Start and verify

The first pull downloads over a gigabyte.

```bash
cd ~/selfhost/librechat
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8112/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8112/health
curl -sS http://localhost:8112/api/config
```

Assert all three and print what you received: the loop ends on `200`, `/health` answers the
literal string `OK`, and `/api/config` returns JSON containing `"appTitle":"LibreChat"` and
`"registrationEnabled":true`. If any misses, stop, run `docker compose logs --tail 40 api` and
name the likely cause: a mongodb that never reports healthy points at step 5, a slow first
start wants more time, and `port is already allocated` means something else holds
8112 (`lsof -nP -iTCP:8112 -sTCP:LISTEN`). A running container is not success.

The first screen at http://localhost:8112 shows the heading `Welcome back` over `Email` and
`Password` fields, a `Sign in` button, and below it `Don't have an account?` with a `Sign up`
link.

STOP: tell the user to open http://localhost:8112, click `Sign up`, create their account, and
wait. Do not continue until they confirm. Upstream documents that the first account becomes the
admin account and that there are no default credentials; with no mail server here, it is the
whole recovery story.

Once they confirm, close registration and restart the app:

```bash
cd ~/selfhost/librechat
sed -i.bak 's/^ALLOW_REGISTRATION=true$/ALLOW_REGISTRATION=false/' .env && rm -f .env.bak
docker compose up -d --force-recreate api
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8112/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8112/api/config
```

Assert: the loop ends on `200`, the config JSON now reads `"registrationEnabled":false`, and the
user reloads in a private window and confirms `Sign up` is gone.

STOP: tell the user to sign in, pick a provider from the model menu, paste their own provider
key into the dialog LibreChat shows, send one message, and wait. Do not report success until
they confirm an answer streamed back. That credential is theirs and billed to them, so never
ask them to paste it to you.

## 8. First backup and restore

Two artifacts. The MongoDB dump holds the accounts, the conversations and the encrypted
provider keys. The config archive holds what rebuilds the service around them, `.env` included:
without `CREDS_KEY` every stored key in the dump is unreadable. The Meilisearch folder is only
an index, rebuilt from MongoDB.

```bash
cd ~/selfhost/librechat
docker compose exec -T mongodb mongodump --archive --gzip --db=LibreChat > backups/librechat-db-$(date +%F).archive.gz
tar -czf backups/librechat-config-$(date +%F).tar.gz compose.yml .env images uploads
ls -lh backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing is stopped, `mongodump` reads
a running database.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy both there with `cp`; in Git Bash a Windows drive is written
`/d/Backups`. Assert: the user confirms both filenames are there.

To restore, in this order: `cd ~/selfhost/librechat`, untar the config archive there first so
compose.yml and .env are back before any container starts, then `docker compose down -v`, the
one place `-v` belongs because it drops the old volume on purpose,
`docker compose up -d mongodb`, wait 30 seconds for healthy, then feed the dump in with
`docker compose exec -T mongodb mongorestore --archive --gzip --drop < backups/librechat-db-$(date +%F).archive.gz`,
then `docker compose up -d`. Sign in and open one old conversation.

## 9. Updating later

New versions are listed at https://github.com/danny-avila/LibreChat/releases. Take both backups
first, then edit the `image:` line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/librechat
docker compose pull
docker compose up -d
docker compose logs --tail 30 api
```

Leave the mongo and Meilisearch tags alone: Meilisearch refuses to open a database written by
another version.

## 10. What will probably go wrong

The machine has plenty of memory and the containers still do not. Docker Desktop runs a virtual
machine with its own memory limit, and mine was set under what three services need, so the app
was killed part way through its first start and came back as a restart loop that looked like a
broken image. Give it 4 GB in Docker Desktop's Resources settings first.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8112 to 0.0.0.0 so a phone can reach it. That puts a login page holding
  provider keys on every network this computer joins.
- Do not add the RAG API or the pgvector database from upstream's compose file. Chatting with
  uploaded documents needs an embeddings credential of its own.
compose.local.ymlthe services, pinned · local layout73 lines

authored from upstream docs, never pasted · 2,832 bytes

# LibreChat · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://www.librechat.ai/docs/local/docker
#   variable reference . https://www.librechat.ai/docs/configuration/dotenv
#
# Three services. Paths are relative to ~/selfhost/librechat/, so one file works
# on macOS, Linux and Windows. Upstream's RAG API and pgvector database are left
# out: they need an embeddings API key of their own. MongoDB sits in a named
# volume, because the image chowns /data/db to a uid Docker Desktop cannot grant
# on Windows, and runs --noauth as upstream ships it, reachable only from the
# app container. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongodb:
    image: mongo:8.0.20@sha256:098862b1339f031900ca66cf8fef799e616d6324fa41b9a263f2ec899552c1ef
    restart: unless-stopped
    command: ["mongod", "--noauth"]
    volumes:
      - librechat-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 10s
      retries: 24

  meilisearch:
    image: getmeili/meilisearch:v1.35.1@sha256:8b57fc3c7f46535ddef3828df1538465ac19d892eb57c9a10da6df0880bd5856
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - ./meili:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7700/health"]
      interval: 10s
      retries: 24
    # No `ports:`: only the app container queries this index.

  api:
    image: ghcr.io/danny-avila/librechat:v0.8.7@sha256:c5db3331b845e1f289f8d04c0c77936c4bbe372f76730a804abc1c37e44d23a9
    restart: unless-stopped
    env_file: ./.env
    environment:
      HOST: 0.0.0.0
      MONGO_URI: mongodb://mongodb:27017/LibreChat
      MEILI_HOST: http://meilisearch:7700
      SEARCH: "true"
      # No TLS here; browsers trust http://localhost anyway.
      SESSION_COOKIE_SECURE: "false"
      # No mail server, so the flow that needs one is off.
      ALLOW_PASSWORD_RESET: "false"
      # `user_provided`: no credential is stored here. It is asked for in
      # the browser and encrypted with CREDS_KEY.
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-user_provided}
      OPENAI_API_KEY: ${OPENAI_API_KEY:-user_provided}
      GOOGLE_KEY: ${GOOGLE_KEY:-user_provided}
    volumes:
      - ./images:/app/client/public/images
      - ./uploads:/app/uploads
      - ./logs:/app/logs
    ports:
      # Loopback only: no other device on the wifi can reach 8112.
      - "127.0.0.1:8112:3080"
    depends_on:
      mongodb:
        condition: service_healthy
      meilisearch:
        condition: service_healthy

volumes:
  librechat-mongo:

agent-readable mirror: /self-host/chatgpt.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned78 lines

authored from upstream docs, never pasted · 3,264 bytes

# LibreChat · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://www.librechat.ai/docs/local/docker
#   variable reference . https://www.librechat.ai/docs/configuration/dotenv
#   reverse proxy ...... https://www.librechat.ai/docs/remote/nginx
#
# Three services: the app, the MongoDB holding accounts and conversations, and
# the Meilisearch that makes those conversations searchable. Upstream's compose
# file adds a RAG API and a pgvector database for chatting with uploaded
# documents; both are left out, because they need an embeddings API key of
# their own and would make this a five-container stack.
#
# MongoDB runs with --noauth, as upstream ships it. That is safe here only
# because it publishes no port: the app container on this private network is
# the one thing that can reach 27017. Digests read 2026-08-06, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongodb:
    image: mongo:8.0.20@sha256:098862b1339f031900ca66cf8fef799e616d6324fa41b9a263f2ec899552c1ef
    restart: unless-stopped
    command: ["mongod", "--noauth"]
    volumes:
      - /srv/librechat/mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 10s
      retries: 24
    # No `ports:`: 27017 never leaves the compose network.

  meilisearch:
    image: getmeili/meilisearch:v1.35.1@sha256:8b57fc3c7f46535ddef3828df1538465ac19d892eb57c9a10da6df0880bd5856
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - /srv/librechat/meili:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7700/health"]
      interval: 10s
      retries: 24
    # No `ports:` either: only the app queries this index.

  api:
    image: ghcr.io/danny-avila/librechat:v0.8.7@sha256:c5db3331b845e1f289f8d04c0c77936c4bbe372f76730a804abc1c37e44d23a9
    restart: unless-stopped
    env_file: /srv/librechat/.env
    environment:
      HOST: 0.0.0.0
      MONGO_URI: mongodb://mongodb:27017/LibreChat
      MEILI_HOST: http://meilisearch:7700
      SEARCH: "true"
      # Caddy terminates TLS and is the only hop in front of this container.
      TRUST_PROXY: "1"
      # No mail server here, so the flow that needs one is off.
      ALLOW_PASSWORD_RESET: "false"
      # Keep this out of search engine results.
      NO_INDEX: "true"
      # `user_provided`: no credential is stored here. LibreChat asks each
      # signed-in person for theirs in the browser and encrypts it with
      # CREDS_KEY.
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-user_provided}
      OPENAI_API_KEY: ${OPENAI_API_KEY:-user_provided}
      GOOGLE_KEY: ${GOOGLE_KEY:-user_provided}
    volumes:
      - /srv/librechat/images:/app/client/public/images
      - /srv/librechat/uploads:/app/uploads
      - /srv/librechat/logs:/app/logs
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8112.
      - "127.0.0.1:8112:3080"
    depends_on:
      mongodb:
        condition: service_healthy
      meilisearch:
        condition: service_healthy
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,050 bytes

# LibreChat · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.librechat.ai/docs/remote/nginx and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also DOMAIN_CLIENT and DOMAIN_SERVER in .env; change it in one place and the
# login cookie stops matching the address the browser is on.

<DOMAIN> {
	# Model replies arrive as a stream of events, one piece at a time.
	# Nothing here compresses or holds that stream: hence no `encode`
	# line, and a proxy that flushes every write.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8112 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8112 {
		flush_interval -1
	}
}
install.shthe same install, no agent168 lines

authored from upstream docs, never pasted · 7,705 bytes

#!/usr/bin/env bash
# LibreChat · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=chat.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.librechat.ai/docs/local/docker
#   https://www.librechat.ai/docs/configuration/dotenv
#   https://www.librechat.ai/docs/remote/nginx
#
# Five secrets are generated here, on this machine: CREDS_KEY, CREDS_IV,
# JWT_SECRET, JWT_REFRESH_SECRET and MEILI_MASTER_KEY. All five go into
# /srv/librechat/.env with mode 600 and none is ever printed.
#
# No provider API key is generated or asked for. LibreChat is configured with
# `user_provided`, so each signed-in person enters their own key in the browser
# and it is stored encrypted with CREDS_KEY. That key is metered per token by
# the provider and billed to whoever owns it.
#
# This script leaves registration OPEN so you can create the first account,
# which upstream documents as the admin account. The closing summary gives you
# the two commands that shut it again. Do that today.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/librechat}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. chat.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; three containers want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# Three owners on purpose: the mongo image chowns its own data directory on
# first start, Meilisearch runs as root inside its container, and the LibreChat
# image runs as its `node` user, which is uid 1000.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mongo" "$APP_DIR/meili"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/images" "$APP_DIR/uploads" "$APP_DIR/logs"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the five secrets, on the server -----------------------------
#
# CREDS_KEY is 32 bytes and CREDS_IV is 16, both written as hex, because that
# is what upstream documents; the app crashes on start-up without them. Read
# them later with
#   grep -E 'CREDS_|JWT_|MEILI_' /srv/librechat/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DOMAIN_CLIENT=https://${DOMAIN_HOST}
		DOMAIN_SERVER=https://${DOMAIN_HOST}
		ALLOW_REGISTRATION=true
		CREDS_KEY=$(openssl rand -hex 32)
		CREDS_IV=$(openssl rand -hex 16)
		JWT_SECRET=$(openssl rand -hex 32)
		JWT_REFRESH_SECRET=$(openssl rand -hex 32)
		MEILI_MASTER_KEY=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-librechat"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and none of 8112, 27017 or 7700 is one of them ------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8112, 27017 and 7700 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first pull downloads more than a gigabyte, and the app takes about a
# minute after that before /health answers.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 api"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q 'OK' \
	|| die "/health answered 200 without OK. Check: docker compose logs --tail 40 api"

config="$(curl -sS "https://${DOMAIN_HOST}/api/config" || true)"
printf '%s' "$config" | grep -q '"appTitle":"LibreChat"' \
	|| die "/api/config did not name LibreChat. Caddy may not be reaching the container."
printf '%s' "$config" | grep -q '"registrationEnabled":true' \
	|| die "/api/config says registration is closed, so nobody can create the first account."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mongodb mongodump --archive --gzip --db=LibreChat > "$APP_DIR/backups/librechat-db-${STAMP}.archive.gz"
sudo tar -czf "$APP_DIR/backups/librechat-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env images uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/librechat-db-${STAMP}.archive.gz" ] || die "the database dump is empty"

cat <<-DONE

	LibreChat is answering at https://${DOMAIN_HOST}

	  1. Registration is OPEN right now, on a public hostname. Go to
	     https://${DOMAIN_HOST} and create your account: upstream documents
	     that the first account registered becomes the admin account and that
	     there are no default credentials. Then close it, today:
	       sed -i 's/^ALLOW_REGISTRATION=true\$/ALLOW_REGISTRATION=false/' $APP_DIR/.env
	       cd $APP_DIR && docker compose up -d --force-recreate api
	     Confirm with: curl -sS https://${DOMAIN_HOST}/api/config
	     It should now say "registrationEnabled":false.
	  2. Nothing here sends mail, so that account is the whole recovery story.
	     Put its password in your password manager now.
	  3. This server holds no provider credential. Sign in, pick a provider from
	     the model menu, and paste your own key into the dialog LibreChat shows.
	     It is stored encrypted with CREDS_KEY, and it is metered per token and
	     billed to you by the provider, not by anything on this box.
	  4. Five secrets were generated into $APP_DIR/.env, mode 600, and none was
	     printed here. Read them yourself with
	       grep -E 'CREDS_|JWT_|MEILI_' $APP_DIR/.env
	  5. First backup written to $APP_DIR/backups: a MongoDB dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight. A dump restored without the .env it
	     was taken with comes back unreadable.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel ChatGPT.

  • The bill changes shape rather than disappearing. LibreChat is a chat interface, not a model: every answer comes from an API key you hold at Anthropic, OpenAI or Google, metered per token and billed to you. A few conversations a day usually costs less than a subscription, an afternoon of long documents through a frontier model can cost more, and nobody caps it for you.
  • You do not get what the subscription bundles. No voice mode, no ChatGPT mobile apps, no image generation or web browsing, no included model access at all, and no chatting with uploaded documents: this install leaves upstream's RAG containers out, because they need a separately billed embeddings key. What you do get is one window over several providers, conversation search, and a history that lives in your database.
  • It is three services, and one of them is a MongoDB with no password. That is what upstream ships, and it is defensible only because this install publishes no port for it: the app container is the one thing that can reach it. If you ever expose 27017, you have handed over every conversation.
  • One key stands between you and readable data. CREDS_KEY encrypts the provider keys people enter in the browser, so a database dump restored without the .env it lives in comes back with credentials nobody can decrypt. The dump and the config archive are one backup in two files.
  • No mail, by design. This install runs with password reset off and no SMTP server, so the first account you register is the whole recovery story: lose that password and the way back in is editing the database.

Where this came from

“The first account you register becomes the admin account. There are no default credentials -- you create your own username and password during registration.”

  • Upstream documents that the first account registered becomes the admin account and that the software ships with no default credentials. source
  • CREDS_KEY is a 32-byte key and CREDS_IV a 16-byte initialisation vector, both written in hex, and upstream states the app will crash on start-up if they are not set. source
  • Setting a provider variable to user_provided makes LibreChat ask each signed-in person for their own API key in the web interface instead of holding one on the server, and SEARCH plus a Meilisearch master key is what turns on search across messages and conversations. source
  • Upstream's own compose file runs the app, MongoDB, Meilisearch, a RAG API and a pgvector database, and starts MongoDB with mongod --noauth and no published port. source
  • The server answers GET /health with the literal string OK, and GET /api/config reports appTitle and whether registration is currently enabled. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host ChatGPT?

    Not ChatGPT itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is LibreChat. One chat window in front of Anthropic, OpenAI and Google, with the conversation history in a database you own. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces ChatGPT?

    LibreChat. One chat window in front of Anthropic, OpenAI and Google, with the conversation history in a database you own. The only one of these that reads like the thing people are actually cancelling: one window, several providers side by side in the same conversation list, search across everything you have ever asked, and accounts with real password login rather than a shared box on your desk. It is MIT with no branding clause and no commercial carve-out, which matters when the whole point is that the software is yours. What it does not do is give you a model. You supply the provider key, the tokens are metered, and on a heavy month that meter can beat the twenty dollars you were paying. LibreChat is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to ChatGPT?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. LibreChat itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: ChatGPT Plus, $20/mo — $240 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the LibreChat install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run LibreChat on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same LibreChat on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8112, which means this computer and nowhere else, so the chat history you build here is not on your phone and is not reachable while the machine is asleep. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.