A $22/Year VPS Running Three Self-Hosted Services
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:
1# local: generate key if you don't have one
2ssh-keygen -t ed25519 -C "[email protected]"
3# paste ~/.ssh/id_ed25519.pub into your provider's SSH key field
4
5# on the server: disable password login
6sed -i 's/^PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
7systemctl 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:
1fallocate -l 2G /swapfile
2chmod 600 /swapfile
3mkswap /swapfile
4swapon /swapfile
5echo '/swapfile none swap sw 0 0' >> /etc/fstabWithout this, CouchDB and Conduit together will occasionally OOM under load.
Firewall:
1ufw allow ssh
2ufw allow 80/tcp
3ufw allow 443/tcp
4ufw allow 3478 # TURN (UDP+TCP)
5ufw allow 5349 # TURN TLS
6ufw allow 49152:65535/udp # TURN relay range
7ufw enableThe TURN ports are for Matrix voice/video – skip them if you’re not setting up coturn.
Docker:
1curl -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.
1apt install -y couchdbThe installer prompts for admin credentials. Set them and keep them somewhere safe.
Configure /opt/couchdb/etc/local.ini:
1[chttpd]
2bind_address = 127.0.0.1
3
4[couch_httpd_auth]
5require_valid_user = true
6
7[httpd]
8enable_cors = true
9
10[cors]
11origins = app://obsidian.md, capacitor://localhost, http://localhost
12credentials = true
13headers = accept, authorization, content-type, origin, referer
14methods = GET, PUT, POST, HEAD, DELETE
15max_age = 36001systemctl restart couchdb
2curl -X PUT http://admin:PASSWORD@localhost:5984/obsidian-syncnginx config:
1server {
2 listen 80;
3 server_name sync.your.domain;
4 location / {
5 proxy_pass http://127.0.0.1:5984;
6 proxy_set_header Host $host;
7 proxy_set_header X-Real-IP $remote_addr;
8 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
9 }
10}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:
1services:
2 conduit:
3 image: matrixconduit/matrix-conduit:latest
4 restart: unless-stopped
5 volumes:
6 - conduit_data:/var/lib/matrix-conduit
7 environment:
8 CONDUIT_SERVER_NAME: "chat.your.domain"
9 CONDUIT_DATABASE_BACKEND: "rocksdb"
10 CONDUIT_ALLOW_REGISTRATION: "true" # flip to false after accounts created
11 CONDUIT_ALLOW_FEDERATION: "false"
12 CONDUIT_MAX_REQUEST_SIZE: "20000000"
13 CONDUIT_LOG: "warn"
14 ports:
15 - "127.0.0.1:6167:6167"
16
17volumes:
18 conduit_data:1cd /opt/conduit && docker compose up -dFor voice and video calls, Matrix uses WebRTC – which needs a TURN server to traverse NAT. coturn handles this:
1apt install -y coturn
2openssl 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=65535nginx config:
1server {
2 listen 80;
3 server_name chat.your.domain;
4 location / {
5 proxy_pass http://127.0.0.1:6167;
6 proxy_set_header Host $host;
7 proxy_set_header X-Real-IP $remote_addr;
8 proxy_set_header Upgrade $http_upgrade;
9 proxy_set_header Connection "upgrade";
10 }
11}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.
1apt install -y calibre # ~300MB, takes a few minutes
2mkdir -p /opt/calibre/books
3calibredb --with-library /opt/calibre/books list # initializes metadata.db
4chown -R 1000:1000 /opt/calibre/books # container runs as uid 1000
5chmod -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:
1services:
2 calibre-web:
3 image: lscr.io/linuxserver/calibre-web:latest
4 restart: unless-stopped
5 environment:
6 - PUID=1000
7 - PGID=1000
8 - TZ=Asia/Kolkata
9 volumes:
10 - calibre_config:/config
11 - /opt/calibre/books:/books
12 ports:
13 - "127.0.0.1:8083:8083"
14
15volumes:
16 calibre_config:nginx config:
1server {
2 listen 80;
3 server_name books.your.domain;
4 location / {
5 proxy_pass http://127.0.0.1:8083;
6 proxy_set_header Host $host;
7 proxy_set_header X-Real-IP $remote_addr;
8 }
9}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:
1certbot --nginx -d sync.your.domain
2certbot --nginx -d chat.your.domain
3certbot --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.