VPS Tutorials
Deploying LobeHub (LobeChat) 2.x on a VPS (2026): Complete Tutorial for Docker, PostgreSQL, Object Storage, Login and HTTPS
This tutorial follows the current LobeHub 2.x architecture and covers VPS sizing, Docker Compose, the database, object storage, login, HTTPS, upgrades and backups.

If you want to put OpenAI, Claude, Gemini, OpenRouter and local models into a single chat interface, LobeHub is one of the most popular options right now. It used to be called LobeChat, and after 2.x the project name, images, login system and deployment structure all changed. Many older tutorials online still use lobehub/lobe-chat, NextAuth or single-container commands, and copying them directly tends to get you stuck on login, the database and file uploads.
This guide deploys the stable release v2.2.12 as of July 2026. The official Docker Compose is not just a single web container; it also includes PostgreSQL, Redis, RustFS and SearxNG. The goal is not "the page opens, so we're done" either, but to get the domain, HTTPS, data persistence, registration restrictions, updates and backups all done properly together.
First, the conclusions:
- If you only connect to external model APIs, a 2-core 4GB VPS can serve as a starting point for personal use;
- For multiple users, or if you upload many documents or knowledge bases, 4 cores, 8GB and 80GB+ of disk are recommended;
- An ordinary CPU VPS is fine for running the LobeHub interface, but not for running 7B or 14B local large models directly;
- If you don't want to maintain the database, object storage, login and backups, using LobeHub Cloud directly will save you a lot of time.
As of July 2026, the official repository has moved to lobehub/lobehub, and the Docker image is lobehub/lobehub. The current stable version is v2.2.12.
The core components of a server deployment include:
| Component | Purpose | Needs persistence |
|---|---|---|
| LobeHub | Web interface, API, Agents and knowledge base logic | Configuration managed via environment variables |
| PostgreSQL | Users, sessions, Agents, file metadata | Required |
| Redis | Session storage and caching | Recommended |
| RustFS | S3-compatible object storage for uploaded files | Required |
| SearXNG | Web search | Keep as needed |
The easiest thing to misunderstand here is "self-hosting". What you self-host is the LobeHub application and data layer. If you connect to OpenAI, Anthropic, Google or OpenRouter, prompts and requests are still sent to the corresponding provider. Only when the models also run on inference services you control, such as Ollama or vLLM, do model requests stay within your environment.
The following are starting points for deployment, not promises about concurrency. Actual usage will be affected by the number of users, document volume, web search, database growth and where the models run.
| Scenario | Recommended starting point | Notes |
|---|---|---|
| Single user, external APIs only | 2 cores, 4GB, 40GB SSD | The full Compose stack runs, but don't pile on other heavy services |
| 3–10 users, occasional document uploads | 4 cores, 8GB, 80GB SSD | Leaves headroom for PostgreSQL, Redis and RustFS |
| Many knowledge bases and files | 4 cores, 8GB and up, separate object storage | Files, indexes and backups keep consuming disk |
| Running large models locally | Separate GPU server | Even if a CPU VPS can load them, the speed is usually impractical |
If you haven't decided on a configuration yet, you can start with How to choose a VPS configuration. This guide deploys a full service stack, so 1 core and 1GB is out of the recommended range.
Thinking through the topology clearly will make troubleshooting much easier later:
浏览器
├─ https://ai.example.com -> Caddy -> LobeHub:3210
└─ https://s3.example.com -> Caddy -> RustFS:9000
LobeHub
├─ PostgreSQL:5432
├─ Redis:6379
├─ RustFS:9000
├─ SearXNG:8080
└─ OpenAI / Anthropic / Gemini / OpenRouter / 自建模型 API
PostgreSQL and Redis should not be exposed to the public internet. The RustFS S3 API needs to be reachable by the browser, so a dedicated subdomain is clearest; the 9001 admin port should only bind to localhost and be accessed via an SSH tunnel when needed.
This article uses Ubuntu 24.04, Docker Engine, Compose v2 and Caddy. First prepare two DNS records, both pointing to the VPS public IP:
ai.example.com
s3.example.com
Update the system and install basic tools:
sudo apt update
sudo apt install -y ca-certificates curl git openssl ufw
Install Docker from the official repository:
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" |
sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker version
sudo docker compose version
Only open SSH, HTTP and HTTPS in the firewall:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
If you are operating over a remote SSH session, be sure to allow OpenSSH before enabling UFW; once the current connection is stable, then enable the other firewall rules.
The default branch of the official repository updates very quickly, so don't run a production instance directly on canary. First pull the stable v2.2.12 locally:
cd /tmp
git clone --depth 1 --branch v2.2.12 \
https://github.com/lobehub/lobehub.git lobehub-v2.2.12
sudo mkdir -p /opt/lobehub
sudo chown "$USER":"$USER" /opt/lobehub
cd /opt/lobehub
Run the official initialization script you downloaded locally, and explicitly tell it to read the v2.2.12 configuration:
bash /tmp/lobehub-v2.2.12/docker-compose/setup.sh \
-l zh_CN \
--url https://raw.githubusercontent.com/lobehub/lobehub/v2.2.12
Choose domain deployment and enter ai.example.com and s3.example.com. The script will download docker-compose.yml, .env, the RustFS bucket configuration and the SearXNG configuration, and prepare KEY_VAULTS_SECRET, AUTH_SECRET, the database password and the RustFS password.
Do not paste the passwords shown in the terminal into chats, tickets or Git repositories. After initialization completes, back up .env first and tighten permissions:
chmod 600 .env
cp .env ".env.initial-$(date +%F-%H%M%S)"
chmod 600 .env.initial-*
The JWKS_KEY in the official example is only a sample value. Following the initialization script's prompts, create a new set of keys on the official JWKS generation page, then replace JWKS_KEY in .env. Don't skip this step.
For debugging convenience, the official example declares host ports for PostgreSQL, Redis and RustFS. On a public VPS you should tighten this:
- LobeHub:
127.0.0.1:3210:3210 - PostgreSQL: remove
5432:5432 - Redis: remove
6379:6379 - RustFS API:
127.0.0.1:9000:9000 - RustFS admin:
127.0.0.1:9001:9001
At the same time, pin the LobeHub image to the stable version verified in this guide:
services:
lobe:
image: lobehub/lobehub:v2.2.12
ports:
- "127.0.0.1:3210:3210"
postgresql:
# 删除 ports,数据库只走 lobe-network
redis:
# 删除 ports,Redis 只走 lobe-network
rustfs:
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
Check the external addresses in .env:
APP_URL=https://ai.example.com
INTERNAL_APP_URL=http://localhost:3210
S3_ENDPOINT=https://s3.example.com
In a Compose deployment, INTERNAL_APP_URL must keep the internal container address. S3_ENDPOINT must not include a bucket path or any other suffix, otherwise uploaded files will fail to open. RustFS uses path-style, and the official Compose already sets S3_ENABLE_PATH_STYLE=1.
After modifying, validate first, don't start directly:
sudo docker compose config >/tmp/lobehub-compose-rendered.yml
grep -nE "image:|published:|DATABASE_URL|S3_ENDPOINT" \
/tmp/lobehub-compose-rendered.yml
Confirm that the rendered output doesn't accidentally write secrets into public logs, and that there is no 0.0.0.0:5432 or 0.0.0.0:6379.
Pull the image and start:
cd /opt/lobehub
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
The first start needs to wait for the PostgreSQL health check, RustFS initialization and database migrations. Check the logs:
sudo docker compose logs --tail=200 lobe postgresql rustfs rustfs-init
Test LobeHub locally first:
curl -I http://127.0.0.1:3210
sudo ss -lntp | grep -E ":3210|:5432|:6379|:9000|:9001"
Normally only 3210, 9000 and 9001 are bound to 127.0.0.1, and 5432 and 6379 should not appear in the public listening list.
Install Caddy:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key |
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt |
sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
Edit /etc/caddy/Caddyfile:
ai.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3210
}
s3.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:9000
}
Validate and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://ai.example.com
curl -I https://s3.example.com
Caddy will request certificates automatically. If it fails, first check whether DNS already points to the current VPS, then check journalctl -u caddy -n 100. For more complete configuration and troubleshooting, see The complete guide to using Caddy as a reverse proxy on a VPS.
LobeHub 2.x uses Better Auth. Email and password login is enabled by default, and if you change nothing, a public instance may allow strangers to register.
The simplest way to restrict a personal instance is to allow only your own email in .env:
[email protected]
This variable only restricts which addresses are allowed to log in or register; it does not verify email ownership. Password resets, magic links or email verification also require SMTP configuration.
If your team uses GitHub, Google, Keycloak or another SSO uniformly, you can disable email and password:
AUTH_DISABLE_EMAIL_PASSWORD=1
AUTH_SSO_PROVIDERS=github
AUTH_GITHUB_ID=your-github-oauth-client-id
AUTH_GITHUB_SECRET=your-github-oauth-client-secret
If OAuth callbacks fail, first check whether APP_URL exactly matches the domain accessed in the browser, including https://. The NEXT_PUBLIC_AUTH_URL from old tutorials is deprecated, and continuing to set it will actually cause deployment failures.
After modifying .env, rebuild the application container:
sudo docker compose up -d --force-recreate lobe
sudo docker compose logs --tail=100 lobe
Model API keys can be written into .env, or configured in LobeHub settings after logging in. Team instances are better off having an administrator manage them centrally, to avoid everyone pasting them repeatedly.
For example, connecting OpenAI:
OPENAI_API_KEY=your-openai-api-key
If you use OpenRouter, LiteLLM or another OpenAI-compatible service, configure the Base URL and Key according to the official provider variables, and don't reuse old variable names based on guesswork. If you want to unify multiple upstreams into one entry point first, you can pair this with Building a LiteLLM unified LLM API gateway on a VPS.
After making changes, rebuild LobeHub and check three things in the web UI:
- The model list loads;
- A new conversation receives complete replies;
- After refreshing the page or logging in again, the conversation still exists.
"The model list is empty" is usually not a broken frontend, but an incorrect API Key, Base URL, model allowlist or network proxy configuration. Look directly at the application logs:
sudo docker compose logs -f --tail=200 lobe
Upload a small image and a test document, and confirm the files are still accessible after refreshing the page. If it fails, check in order:
curl -I https://s3.example.com
sudo docker compose ps rustfs rustfs-init
sudo docker compose logs --tail=200 rustfs rustfs-init lobe
grep -E "S3_ENDPOINT|RUSTFS_|S3_ENABLE_PATH_STYLE" .env
Common issues include:
S3_ENDPOINTis stillhttp://localhost:9000, so the browser can't reach it at all;- A bucket path was incorrectly appended to the Endpoint;
- The RustFS subdomain has no HTTPS;
- Port 9000 is blocked by the firewall or reverse proxy;
- The Access Key and Secret Key in
.envdon't match the values in Compose.
Don't expose 9000 directly to the public internet just to save effort. Going through a dedicated HTTPS subdomain is easier to control and won't cause mixed content errors in the browser.
Don't set production to automatically pull latest every few minutes. LobeHub updates quickly, and database migrations change too. The safe process is:
cd /opt/lobehub
# 1. 记录当前镜像
sudo docker compose images
# 2. 完成下一节的备份
# 3. 把 docker-compose.yml 中的镜像改成已确认的新版本
sudo docker compose pull lobe
sudo docker compose up -d lobe
# 4. 查看迁移和启动日志
sudo docker compose logs --tail=300 lobe postgresql
If you get a database migration error after upgrading, don't repeatedly restart and overwrite the scene. Save the logs and confirm the migration notes for the old and new versions; when you need to roll back, restore the old image and the pre-upgrade database backup. Just changing the image tag back while continuing to use a database already migrated by the new version may not run correctly.
A complete restore requires at least:
- PostgreSQL data;
- RustFS object files;
- Redis data (if you want to preserve session cache state);
.env, JWKS, Caddyfile and Compose configuration;- The image version currently in use.
Create a backup directory and export the database:
cd /opt/lobehub
mkdir -p "backups/$(date +%F)"
sudo docker compose exec -T postgresql \
pg_dump -U postgres -d lobechat -Fc \
>"backups/$(date +%F)/lobehub-postgres.dump"
cp .env docker-compose.yml bucket.config.json searxng-settings.yml \
"backups/$(date +%F)/"
sudo cp /etc/caddy/Caddyfile "backups/$(date +%F)/Caddyfile"
sudo chown -R "$USER":"$USER" backups
chmod 600 "backups/$(date +%F)/.env"
The default database name is lobechat; if you changed LOBE_DB_NAME, the export command must change accordingly.
RustFS and Redis use Docker Volumes. First check the actual volume names:
sudo docker volume ls | grep lobehub
In a short downtime window, stop writes, then package the volumes:
sudo docker compose stop lobe
sudo docker run --rm \
-v lobehub_rustfs-data:/source:ro \
-v "$PWD/backups/$(date +%F)":/backup \
alpine tar -czf /backup/rustfs-data.tar.gz -C /source .
sudo docker run --rm \
-v lobehub_redis_data:/source:ro \
-v "$PWD/backups/$(date +%F)":/backup \
alpine tar -czf /backup/redis-data.tar.gz -C /source .
sudo docker compose up -d
If docker volume ls shows different names, replace the command with the actual results. After the backup completes, sync the directory to another machine or object storage; keeping backups on the same VPS disk as production data means they disappear together if the machine fails.
Before restoring, rehearse on a test VPS first. Example database restore:
cat lobehub-postgres.dump |
sudo docker compose exec -T postgresql \
pg_restore -U postgres -d lobechat --clean --if-exists
Restore .env and the keys first, then restore PostgreSQL and the object files, and finally start LobeHub. If the keys change, even with the database and files present, logins may fail or saved credentials may not decrypt.
For a more complete off-site backup process, continue with VPS backup and restore drill.
sudo docker compose ps
curl -I http://127.0.0.1:3210
sudo systemctl status caddy --no-pager
sudo journalctl -u caddy -n 100 --no-pager
If 3210 is reachable locally but the domain isn't, the problem is most likely DNS, Caddy or the firewall. If 3210 isn't reachable locally either, check the LobeHub logs.
sudo docker compose ps postgresql
sudo docker compose logs --tail=200 postgresql lobe
sudo docker compose exec postgresql \
pg_isready -U postgres -d lobechat
Focus on checking POSTGRES_PASSWORD, LOBE_DB_NAME and the rendered DATABASE_URL. Don't open 5432 to the whole internet just for debugging.
Check APP_URL, AUTH_SECRET, the system time and browser cookies. After changing the domain from HTTP to HTTPS, rebuild the LobeHub container so the environment variables actually take effect:
timedatectl status
grep "^APP_URL=" .env
grep -q "^AUTH_SECRET=." .env && echo "AUTH_SECRET 已设置"
sudo docker compose up -d --force-recreate lobe
Don't post the value of AUTH_SECRET to public logs.
sudo docker compose logs --tail=400 lobe postgresql
sudo docker compose images
Confirm whether you crossed a major version, whether the database migration failed, or whether the new image requires new environment variables. Keep the logs and existing data first, then decide whether to continue migrating or roll back from backup.
- Both
ai.example.comands3.example.comare HTTPS; - PostgreSQL and Redis are not listening publicly;
-
.envhas permissions 600, and keys have not been committed to Git; -
AUTH_ALLOWED_EMAILSor SSO has restricted stranger registration; - The model list, conversation persistence, and image and document uploads have all been verified;
- The LobeHub image is pinned to a confirmed version;
- PostgreSQL, RustFS, configuration and keys all have off-site backups;
- At least one test-environment restore has been completed.
The value of self-hosting LobeHub is putting chat, Agents, files and a multi-model entry point into a service stack you control. The cost is also direct: you are responsible for the database, object storage, login and updates. Starting with 2 cores and 4GB is fine for personal use; for formal team use, finish registration restrictions, backup and restore, and upgrade rollback before inviting others in.