Why self-host Joplin?#

Joplin Cloud costs money, and I don’t love my notes sitting on someone else’s server. The Pi was already running 24/7 anyway, so spinning up a sync server was a natural fit. Total cost: ₹0/month.

The alternatives I considered:

OptionWhy I skipped it
Joplin CloudPaid, data on third-party servers
Nextcloud syncToo heavy for a 2GB Pi just for notes
Dropbox/OneDriveWorks but sync conflicts are painful
Self-hosted Joplin Server✅ Free, fast, full control

Stack#

  • Joplin Server — the official sync backend (Docker)
  • PostgreSQL 15 — note storage and metadata (Docker)
  • nginx — reverse proxy with SSL termination
  • Bash script — automated daily pg_dump backups
  • Tailscale — remote access from anywhere

Prerequisites#

  • Raspberry Pi 4B running headless Raspberry Pi OS (64-bit)
  • Docker and Docker Compose installed
  • A domain or local hostname for nginx (e.g., joplin.home.lan)

Step 1 — Docker Compose setup#

Create a working directory:

mkdir -p ~/docker/joplin && cd ~/docker/joplin

Create the compose.yaml:

services:
  joplin:
    image: joplin/server:latest
    container_name: joplin-server
    restart: unless-stopped
    ports:
      - "22300:22300"
    environment:
      - APP_BASE_URL=https://joplin.yourdomain.com
      - APP_PORT=22300
      - DB_CLIENT=pg
      - POSTGRES_HOST=db
      - POSTGRES_PORT=5432
      - POSTGRES_DATABASE=joplin
      - POSTGRES_USER=joplin
      - POSTGRES_PASSWORD=changeme
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:15
    container_name: joplin-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=joplin
      - POSTGRES_USER=joplin
      - POSTGRES_PASSWORD=changeme
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U joplin"]
      interval: 10s
      timeout: 5s
      retries: 5

Bring it up:

docker compose up -d

Note: The healthcheck on the db service ensures Joplin Server doesn’t start until PostgreSQL is actually ready to accept connections — avoids the classic “connection refused on first boot” issue.

Step 2 — nginx reverse proxy#

server {
    listen 80;
    server_name joplin.yourdomain.com;

    client_max_body_size 200M;  # allow large note attachments

    location / {
        proxy_pass http://localhost:22300;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Key detail: the client_max_body_size 200M is important — without it, nginx will reject note attachments (PDFs, images) larger than the default 1MB.

Step 3 — Automated daily backups#

The database holds everything — notes, notebooks, tags, attachments. Losing it would be painful. A simple pg_dump cron job handles this:

#!/bin/bash
# /home/pi/scripts/backup-joplin.sh

BACKUP_DIR="/mnt/ssd/backups/joplin"
DATE=$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"

# Dump the database
docker exec joplin-db pg_dump -U joplin joplin \
  > "$BACKUP_DIR/joplin-$DATE.sql"

# Keep only last 7 days
find "$BACKUP_DIR" -name "*.sql" -mtime +7 -delete

echo "[$(date)] Joplin backup completed: joplin-$DATE.sql"

Make it executable and add to crontab:

chmod +x /home/pi/scripts/backup-joplin.sh
# crontab -e
0 2 * * * /home/pi/scripts/backup-joplin.sh >> /var/log/joplin-backup.log 2>&1

Backups run at 2 AM daily, old dumps auto-deleted after 7 days. The SSD has plenty of room — each daily dump is only a few MB.

Step 4 — Connect the Joplin app#

On every device (phone, laptop, desktop):

  1. Open Joplin → SettingsSynchronisation
  2. Set sync target to Joplin Server
  3. Enter the server URL: https://joplin.yourdomain.com
  4. Log in with default credentials (admin@localhost / admin) and change the password immediately

Sync happens automatically in the background. Notes, notebooks, tags, and attachments all stay in sync across every device.

Gotchas I hit along the way#

  • First-time login: The default admin credentials are admin@localhost / admin — easy to miss in the docs. Change them right after first login.
  • ARM image support: The official joplin/server image supports ARM64, so it runs natively on the Pi without emulation. No performance issues.
  • Database migrations: On version upgrades, Joplin Server runs migrations automatically on startup. Just pull the new image and docker compose up -d.
  • Disk space: PostgreSQL data lives on the SSD (./pgdata). Keep an eye on it if you store lots of large attachments.

Result#

Notes sync instantly across all devices — phone, laptop, desktop. Backups run at 2 AM daily and I sleep fine. The whole thing uses barely any resources on the Pi:

$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
NAME             CPU %     MEM USAGE / LIMIT
joplin-server    0.15%     85MiB / 1.8GiB
joplin-db        0.05%     35MiB / 1.8GiB

Total memory footprint: ~120MB. Perfectly fine on the 2GB Pi.


Joplin is running as a service on the homelab → view service details