I wanted three things: a private Obsidian sync server, a Matrix chat instance for personal use, and a self-hosted book library. The obvious path is a cheap VPS, a few Docker containers, and nginx as a reverse proxy in front of everything.
The whole stack runs on a budget VPS -- 1 vCPU, 2.9GB RAM, 30GB disk, $22/year. All three services are running with room to spare.
Initial Setup
SSH key auth first, password auth off:
# local: generate key if you don't have one
ssh-keygen -t ed25519 -C "your_email@example.com"
# paste ~/.ssh/id_ed25519.pub into your provider's SSH key field
# on the server: disable password login
sed -i 's/^PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshdTest key login before disabling password auth. If you lock yourself out, you're recovering via the provider's console.
Swap -- critical on 3GB RAM:
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstabWithout this, CouchDB and Conduit together will occasionally OOM under load.
Firewall:
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 3478 # TURN (UDP+TCP)
ufw allow 5349 # TURN TLS
ufw allow 49152:65535/udp # TURN relay range
ufw enableThe TURN ports are for Matrix voice/video -- skip them if you're not setting up coturn.
Docker:
curl -fsSL https://get.docker.com | shService 1: Obsidian LiveSync (CouchDB)
LiveSync uses CouchDB as the sync backend. Install it directly on the host rather than in Docker -- it's simpler to configure and easier to troubleshoot.
apt install -y couchdbThe installer prompts for admin credentials. Set them and keep them somewhere safe.
Configure /opt/couchdb/etc/local.ini:
[chttpd]
bind_address = 127.0.0.1
[couch_httpd_auth]
require_valid_user = true
[httpd]
enable_cors = true
[cors]
origins = app://obsidian.md, capacitor://localhost, http://localhost
credentials = true
headers = accept, authorization, content-type, origin, referer
methods = GET, PUT, POST, HEAD, DELETE
max_age = 3600systemctl restart couchdb
curl -X PUT http://admin:PASSWORD@localhost:5984/obsidian-syncnginx config:
server {
listen 80;
server_name sync.your.domain;
location / {
proxy_pass http://127.0.0.1:5984;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}Get a cert: certbot --nginx -d sync.your.domain
In Obsidian: install the LiveSync community plugin, set the CouchDB URI to your domain, enter credentials, set a database name and an E2E passphrase. Enable sync. The passphrase encrypts vault content before it leaves your device -- CouchDB never sees plaintext.
Service 2: Private Matrix Chat (Conduit + coturn)
Conduit is a Matrix homeserver written in Rust -- much lighter than Synapse, which would be too heavy for 3GB RAM. I run it with federation disabled -- this is a personal instance, not connected to the public Matrix network.
/opt/conduit/docker-compose.yml:
services:
conduit:
image: matrixconduit/matrix-conduit:latest
restart: unless-stopped
volumes:
- conduit_data:/var/lib/matrix-conduit
environment:
CONDUIT_SERVER_NAME: "chat.your.domain"
CONDUIT_DATABASE_BACKEND: "rocksdb"
CONDUIT_ALLOW_REGISTRATION: "true" # flip to false after accounts created
CONDUIT_ALLOW_FEDERATION: "false"
CONDUIT_MAX_REQUEST_SIZE: "20000000"
CONDUIT_LOG: "warn"
ports:
- "127.0.0.1:6167:6167"
volumes:
conduit_data:cd /opt/conduit && docker compose up -dFor voice and video calls, Matrix uses WebRTC -- which needs a TURN server to traverse NAT. coturn handles this:
apt install -y coturn
openssl rand -hex 32 # generate a static auth secret, keep it/etc/turnserver.conf:
listening-port=3478
tls-listening-port=5349
fingerprint
use-auth-secret
static-auth-secret=YOUR_SECRET
realm=chat.your.domain
cert=/etc/letsencrypt/live/chat.your.domain/fullchain.pem
pkey=/etc/letsencrypt/live/chat.your.domain/privkey.pem
min-port=49152
max-port=65535
nginx config:
server {
listen 80;
server_name chat.your.domain;
location / {
proxy_pass http://127.0.0.1:6167;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Once Conduit is up: go to Element Web, set homeserver to your domain, register your accounts while ALLOW_REGISTRATION is true, then flip it to false and restart the container. Anyone who finds your homeserver URL can register while it's open.
Service 3: Calibre-Web
Calibre-Web gives you a browser-based library for EPUB, PDF, and MOBI files. The Docker image is from linuxserver.io.
Critical step before starting the container: Calibre-Web needs a properly initialized metadata.db. Not an empty file, not a directory -- a valid Calibre library database. It must be created by calibredb.
apt install -y calibre # ~300MB, takes a few minutes
mkdir -p /opt/calibre/books
calibredb --with-library /opt/calibre/books list # initializes metadata.db
chown -R 1000:1000 /opt/calibre/books # container runs as uid 1000
chmod -R 755 /opt/calibre/booksDo this before starting the container. If you start it first and hit "invalid path" or "DB not writable", this is why.
/opt/calibre/docker-compose.yml:
services:
calibre-web:
image: lscr.io/linuxserver/calibre-web:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Kolkata
volumes:
- calibre_config:/config
- /opt/calibre/books:/books
ports:
- "127.0.0.1:8083:8083"
volumes:
calibre_config:nginx config:
server {
listen 80;
server_name books.your.domain;
location / {
proxy_pass http://127.0.0.1:8083;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}Default login is admin / admin123. Change it immediately. Set library path to /books.
Cloudflare in Front
All three subdomains go through Cloudflare. DNS A records point to the VPS IP, proxy status set to orange cloud (proxied) for all three. SSL mode set to Full -- not Full Strict, since the Let's Encrypt cert on origin is valid enough and Full Strict adds complexity for no real benefit here.
certbot handles the Let's Encrypt certs on the origin:
certbot --nginx -d sync.your.domain
certbot --nginx -d chat.your.domain
certbot --nginx -d books.your.domainResource Usage
All three services running simultaneously:
| Service | RAM |
|---|---|
| Calibre-Web | ~159MB |
| Conduit | ~62MB |
| CouchDB | ~80MB |
| nginx + OS | ~453MB |
| Total | ~754MB / 2.9GB (26%) |
Swap barely touched. Disk at ~6GB of 30GB. Plenty of headroom for adding services.
Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| SSH asks for password after key setup | Public key not on server | ssh-copy-id or paste key in provider panel |
| Calibre-Web says "invalid path" | metadata.db missing or wrong schema | Install calibre on host, run calibredb list to init |
| Calibre-Web says "DB not writable" | Files owned by root, container runs as uid 1000 | chown -R 1000:1000 /opt/calibre/books |
| Voice/video calls fail in Matrix | WebRTC can't traverse NAT | Install coturn, configure static-auth-secret |
| CouchDB sync drops randomly | Missing CORS headers | Double-check the [cors] section in local.ini |
The whole thing took an afternoon to set up. $22/year for services I fully control and can inspect end to end.