What Joplin Server is and what it needs
Joplin Server is the sync target the Joplin project builds for its own apps. The client source describes it as giving synchronisation, better performance and access to Joplin-specific sharing features, which a plain WebDAV target does not have. By default it keeps item contents (notes, tags, attachments) inside the database; the STORAGE_DRIVER variable can move them to a directory on disk or to S3.
The project publishes hardware figures only for Joplin Server Business, its commercial edition, and describes them as sized for moderate load with several concurrent users. Nothing is published for a single person's notebooks.
| Resource | Published for Joplin Server Business | Practical reading |
|---|---|---|
| CPU | 2 cores, 4 threads | Every dedicated line we run meets it |
| Memory | 4 GB minimum, 8 GB recommended | Server, PostgreSQL and the OS share it |
| Storage | 50 GB SSD minimum | Attachments count here unless STORAGE_DRIVER moves them |
| Database | PostgreSQL 16.8+ for production; SQLite for testing only | The compose sample uses postgres:16 |
| Network | 1 Gbps Ethernet | Included on most lines; the rest have 500 Mbps |
Choosing the machine
A notes server is a small, private, always-on job, which argues for one whole machine rather than a share of one: no hypervisor, nobody else on the hardware, root, and a port nobody meters, so a phone downloading every attachment adds nothing to the invoice. The dedicated server range lists every line with its memory, disks and cities.
- Moonshot cartridges are sized for one service per machine. Joplin Server and its database fit that pattern. Most cartridges have power control only; the m510 and m710x add a console.
- Single-socket servers come with a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), worth having when a firewall change locks SSH out.
- Put it in the city nearest your devices; the five locations each have a test IP to ping first.
Racked machines are handed over in about 30 minutes once payment is approved. IPv6 is not in the default build: add it at checkout at no charge, then keep the IPv6 firewall rules below in mind.
Firewall, directory and secrets
Install Docker Engine from Docker's own instructions (Engine 28 or later; see the compose section for why), then open a root shell with sudo -i on its own and paste the block below. It sets ufw to write IPv6 rules before any rule is added, enables the firewall with SSH only, and writes the .env file at mode 600 with a random database password that never appears on a command line.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
mkdir -p /opt/joplin && cd /opt/joplin
umask 077
cat > .env <<EOF
APP_BASE_URL=http://localhost:22300
APP_PORT=22300
DB_CLIENT=pg
POSTGRES_USER=joplin
POSTGRES_PASSWORD=$(openssl rand -hex 24)
POSTGRES_DATABASE=joplin
POSTGRES_PORT=5432
EOF
chmod 600 .env
ls -l .env
APP_BASE_URL starts as http://localhost:22300 on purpose, so the first admin login works through a tunnel. It changes to the public HTTPS address once the admin password is no longer the default.
The compose file
This is the official docker-compose.server.yml cut down to the two services the server profile runs: the Transcribe services and the profiles are gone, the image is pinned to a release from the server changelog, and port 22300 is published on 127.0.0.1 only. Save it as /opt/joplin/docker-compose.yml.
services:
db:
image: postgres:16
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_DB=${POSTGRES_DATABASE}
app:
image: joplin/server:3.7.3
depends_on:
- db
ports:
- "127.0.0.1:22300:22300"
restart: unless-stopped
environment:
- APP_PORT=22300
- APP_BASE_URL=${APP_BASE_URL}
- DB_CLIENT=pg
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DATABASE=${POSTGRES_DATABASE}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_HOST=db
The sample's own "22300:22300" mapping carries no address, which publishes the port on every host address, 0.0.0.0 and [::]. Packets to it bypass ufw on IPv4, and on IPv6 only if Docker manages IPv6 itself; otherwise the [::] listener is docker-proxy, and ufw does filter that. Binding to 127.0.0.1 sidesteps both, and on Docker Engine 28+ such a port is reachable from the host alone (moby#45610). From /opt/joplin, docker compose up -d starts both containers; watch docker compose logs app for errors while it creates its tables.
Replacing the default admin password
The README states that a new install has an admin user with email admin@localhost and password admin. Replace it while only the host itself can reach the app:
- From your workstation, open a tunnel through your own login:
ssh -L 22300:127.0.0.1:22300 you@your-server. - Browse to
http://localhost:22300and sign in as admin@localhost / admin. - Open Profile in the upper right and set a long password.
- On the Users page, create a separate non-admin user for syncing, as the README recommends.
Why the tunnel works: the server compares the host of each request with the host in APP_BASE_URL and refuses a mismatch with an Invalid origin message (isValidOrigin in routeUtils.ts). With APP_BASE_URL set to http://localhost:22300, the tunnelled browser matches. Self-signup is off by default (SIGNUP_ENABLED is false in env.ts), so accounts exist only when the admin creates them.
HTTPS through a reverse proxy
- Point a DNS name such as notes.example.com at the server.
- In
.env, setAPP_BASE_URL=https://notes.example.com, then recreate the app container by repeatingdocker compose up -d. - In the nginx site for that name, use
proxy_pass http://127.0.0.1:22300;withproxy_set_header Host $host;. Without the Host line nginx sends127.0.0.1:22300as the host and the origin check fails. - Raise
client_max_body_sizein that site: nginx's default of 1 MB refuses larger attachments. - Get a certificate with your ACME client, then open the web ports with
ufw allow 80,443/tcp(IPv4 and IPv6, since IPV6=yes was set first).
In each Joplin app, open the Synchronisation settings, pick Joplin Server, and enter the HTTPS address with the sync user's email and password. From now on the admin pages are reached at the public address; the tunnel URL no longer matches APP_BASE_URL.
End-to-end encryption on the clients
Encryption is a client feature, so the server and its backups hold ciphertext for notes, notebooks, tags and attachments once it is on. Joplin's E2EE guide sets the order:
- Enable encryption on one device first, preferably desktop or terminal, and choose the master key password. It cannot be recovered.
- Synchronise and let the full re-upload finish, even if it runs overnight.
- Synchronise each other device in turn; it receives the master key and asks for that password.
- Never enable encryption on two devices in parallel, or you end up with several keys.
Backups and upgrades
With the default storage, one pg_dump captures every note. Create the target with mkdir -m 700 /var/backups/joplin, then put these two lines in the cron file /etc/cron.d/joplin-backup:
30 3 * * * root umask 077; cd /opt/joplin && docker compose exec -T db pg_dump -U joplin -Fc joplin > /var/backups/joplin/joplin-$(date +\%F).dump
15 4 * * * root find /var/backups/joplin -name 'joplin-*.dump' -mtime +14 -delete
Copy the dumps and .env off the machine yourself; a backup on the same disks is not one. To restore, stop the app with docker compose stop app, then pipe a dump into docker compose exec --no-TTY db pg_restore -U joplin -d joplin --clean --if-exists.
To upgrade, read the server changelog, take a fresh dump, change the image tag, then pull and recreate: docker compose pull, then docker compose up -d. DB_AUTO_MIGRATION defaults to true in env.ts, so the new release migrates the schema when it starts; our advice is to treat the dump as the way back rather than the old tag. Keep the database on postgres:16; a new PostgreSQL major version is its own dump-and-restore job.
Licence, and when to use something else
The Joplin repository is AGPL-3.0-or-later except where a directory carries its own licence file, and packages/server does: Joplin Server is under the Joplin Server Personal Use License (v.1, 21 December 2021). Its clause 2.3 says "the Software may be used for personal non-commercial purposes only" unless the licensor authorises otherwise in writing, and its examples rule out a business granting access, even free of charge. An organisation needs Joplin Server Business, which the README offers under a commercial licence.
If a personal-use server does not suit you, Joplin also syncs over WebDAV. Its docs list Nextcloud as a target and Seafile among the WebDAV services known to work: see Nextcloud on a dedicated server or Seafile self-hosted. You lose the Joplin-specific sharing features.
Questions
What are the default Joplin Server admin credentials?
A fresh install creates admin@localhost with the password admin, according to the server README. Change the password from the Profile page before the server is reachable from the internet. An SSH tunnel to a port bound to 127.0.0.1 lets you do that without exposing anything.
Why does the server answer Invalid origin?
The server compares the host a request arrives on with the host in APP_BASE_URL and refuses a mismatch. Behind nginx, the usual cause is a missing proxy_set_header Host $host line. Locally, it means you are browsing to an address other than the one APP_BASE_URL names.
Is SQLite good enough for one user?
The .env sample says SQLite is the default mostly for testing and evaluating the server, and that you will want PostgreSQL for production. The Business edition's requirements say the same. PostgreSQL also gives you pg_dump, which makes the backup a single command.
Can my company host it for its staff?
Not under the Personal Use License, which limits the server to non-commercial use and rules out a business granting access to it. Joplin sells Joplin Server Business under a commercial licence for organisations. The desktop and mobile apps remain AGPL-3.0-or-later either way.
Where do attachments end up?
In PostgreSQL, unless STORAGE_DRIVER sends item contents to a directory or to S3. The README covers moving an existing install with a fallback driver, starting in ReadAndWrite mode. If you move to the filesystem, back that directory up too.