/posts/drafts/install-pocketbase-digital-ocean
How to Install PocketBase on DigitalOcean
TL;DR
A single Go binary handles your database, API, admin UI, and TLS termination. No reverse proxy, no dependency manager, no daemon supervisor beyond systemd. Full setup: droplet, binary transfer, systemd service, DNS, SQLite backup via rclone or Litestream.
Guide
Copy one file to a $4 DigitalOcean droplet, write a systemd unit, point DNS at it. Hit the URL. HTTPS is already working -- certificate and all -- with no nginx, no Caddy, no certbot cron. The whole thing takes about 15 minutes.
That one file is PocketBase: a single Go binary containing a SQLite database, a REST and realtime API, an admin dashboard, and an HTTPS server with automatic Let's Encrypt provisioning. No PostgreSQL, no package manager, no reverse proxy. The only decision you need to make upfront is your backup strategy, because PocketBase stores everything in a single pb_data/ directory and that directory is the entire state of your application.
A $4 Droplet Is Sufficient for Most Workloads
A 512 MB droplet running Ubuntu 22.04 or 24.04 handles PocketBase without breaking a sweat. There is no separate database server eating memory -- SQLite is embedded in the binary, single-file, single-process. The OS gets its share, PocketBase gets the rest, and nothing fights over RAM. Write serialization is what limits SQLite at scale, not read throughput, but on a small droplet serving a small API you will hit neither.
Spin up a droplet with these settings:
- Image: Ubuntu 22.04 or 24.04 LTS (x86_64)
- Plan: Basic shared CPU, $4/month (1 vCPU, 512 MB RAM, 10 GB SSD)
- Authentication: SSH key (add yours during creation)
Once it boots, SSH in as root:
ssh root@YOUR_DROPLET_IP
Add the droplet's IP to your local
~/.ssh/configas a named host. You will SSH in repeatedly during setup and later during upgrades.
wget and chmod Are the Full Install
There is no apt install, no PPA, no Docker image required. PocketBase releases are zip files at github.com/pocketbase/pocketbase/releases. Download the linux_amd64.zip directly on the server:
mkdir -p /root/pb
# Replace X.Y.Z with the current version from the releases page
wget https://github.com/pocketbase/pocketbase/releases/download/vX.Y.Z/pocketbase_X.Y.Z_linux_amd64.zip
unzip pocketbase_X.Y.Z_linux_amd64.zip -d /root/pb/
chmod +x /root/pb/pocketbase
If you downloaded the zip locally first, unzip it and rsync the binary up:
# On your local machine after unzipping
rsync -avz -e ssh ./pocketbase root@YOUR_DROPLET_IP:/root/pb/
Verify the binary runs:
/root/pb/pocketbase --version
It should print the version string and exit. If it prints nothing or errors, the binary did not transfer correctly -- redownload and re-extract.
The systemd Unit Does the Heavy Lifting
Without a process supervisor, PocketBase dies with the SSH session and does not restart on crash or reboot. systemd handles both. Create the unit file:
vi /lib/systemd/system/pocketbase.service
Paste this content, replacing yourdomain.com with your actual domain or subdomain:
[Unit]
Description=PocketBase Service
After=network.target
[Service]
Type=simple
User=root
Group=root
LimitNOFILE=4096
Restart=always
RestartSec=5s
StandardOutput=append:/root/pb/errors.log
StandardError=append:/root/pb/errors.log
ExecStart=/root/pb/pocketbase serve --http="yourdomain.com:80" --https="yourdomain.com:443"
[Install]
WantedBy=multi-user.target
Enable and start it:
systemctl enable pocketbase.service --now
systemctl status pocketbase.service
The
ExecStartline uses your domain name, not0.0.0.0. PocketBase uses the hostname in--httpand--httpsto request a Let's Encrypt certificate via ACME HTTP-01 challenge. If you pass an IP address instead of a domain, TLS provisioning silently fails and the service falls back to HTTP only.
The LimitNOFILE=4096 line raises the open file descriptor limit. SQLite holds the database file open and PocketBase opens additional files for uploads, logs, and the admin UI. The default system limit (1024 on older Ubuntu images) causes intermittent failures under modest load -- 4096 avoids this.
Restart=alwayswithRestartSec=5smeans systemd restarts PocketBase 5 seconds after any crash. If PocketBase crashes immediately on restart (misconfiguration, port conflict), systemd enters a restart loop. Check/root/pb/errors.logbefore assuming a hardware or network problem.
SQLite Backup Has One Correct Tool
Run cp data.db data.db.bak while PocketBase is running. Restore from it later. Your data is corrupted.
SQLite is single-writer, and a raw file copy does not coordinate with the write-ahead log. A write that lands mid-copy produces a backup file that SQLite cannot open. This is the most common way people lose PocketBase data.
Everything lives in /root/pb/pb_data/:
data.db-- the main SQLite databasestorage/-- uploaded files and assetslogs.db-- request and event logs (safe to exclude from backups)types.d.ts-- generated TypeScript types (regenerable, lowest priority)
Two tools back this up correctly.
rclone + SQLite .backup pragma (simple, push to cloud storage):
apt install rclone -y
# Configure a remote: DigitalOcean Spaces, S3, Backblaze B2, etc.
rclone config
# Safe database dump using the SQLite backup API
sqlite3 /root/pb/pb_data/data.db ".backup '/tmp/data.db.bak'"
# Sync the safe copy and the rest of pb_data (excluding live logs)
cp /tmp/data.db.bak /root/pb/pb_data/data.db.bak
rclone sync /root/pb/pb_data/ remote:your-bucket/pb_data/ --exclude "*.log" --exclude "data.db"
rclone copyto /tmp/data.db.bak remote:your-bucket/pb_data/data.db
Put this in a nightly cron job (RPO = 24 hours):
crontab -e
# Add:
# 0 3 * * * sqlite3 /root/pb/pb_data/data.db ".backup '/tmp/data.db.bak'" && rclone copyto /tmp/data.db.bak remote:your-bucket/pb_data/data.db && rclone sync /root/pb/pb_data/storage/ remote:your-bucket/pb_data/storage/
Litestream (continuous WAL replication, RPO = seconds):
Litestream streams SQLite write-ahead log frames to object storage in near-real-time. It is the better choice when you cannot tolerate a full day of data loss.
wget https://github.com/benbjohnson/litestream/releases/latest/download/litestream-linux-amd64.tar.gz
tar -xzf litestream-linux-amd64.tar.gz -C /usr/local/bin/
Configure /etc/litestream.yml:
dbs:
- path: /root/pb/pb_data/data.db
replicas:
- url: s3://your-bucket/data.db
Run Litestream as a systemd service alongside PocketBase. It wraps the PocketBase process and intercepts WAL commits before they are checkpointed.
Never back up a live SQLite database by copying the file directly. The
.backuppragma is safe because it uses the SQLite backup API, which coordinates with the write-ahead log. A rawcp data.dbdoes not.
certmagic Handles TLS -- No Reverse Proxy Required
Start PocketBase with --https="yourdomain.com:443". Open the URL in a browser. The padlock is there -- valid certificate, no warnings, no certbot, no cron renewal. You did not configure any of that.
Here is what happened behind the scenes: PocketBase uses the certmagic library (the same one Caddy uses) to:
- Request a certificate from Let's Encrypt via HTTP-01 challenge on port 80
- Store the issued certificate in
pb_data/ - Serve HTTPS on port 443 and auto-renew before expiry
PocketBase must bind ports 80 and 443. On Ubuntu, non-root processes cannot bind ports below 1024 by default. Running as root (as the unit file above does) sidesteps this. To run as a non-root user instead, grant the capability:
setcap cap_net_bind_service=+ep /root/pb/pocketbase
UFW firewall rules:
ufw allow 22/tcp # SSH -- do this first or you lock yourself out
ufw allow 80/tcp # HTTP (required for ACME challenge)
ufw allow 443/tcp # HTTPS
ufw enable
Run
ufw allow 22/tcpbeforeufw enable. Enabling UFW without an SSH rule drops your current connection and locks you out. DigitalOcean's console gives recovery access, but it is a frustrating detour.
DNS: add an A record pointing your domain or subdomain to the droplet IP. Let's Encrypt's HTTP-01 challenge requires the domain to resolve to the server before PocketBase can obtain a certificate. If DNS has not propagated when PocketBase first starts, certificate issuance fails and the service falls back to HTTP. Restarting after propagation triggers a fresh ACME attempt.
SQLite Limits Where Postgres Wins
Push 50 concurrent writes at PocketBase. Some will fail. SQLite serializes writes -- one at a time, full stop. This is where the single-binary tradeoff bites.
| Axis | PocketBase (binary) | nginx + Postgres app |
|---|---|---|
| Setup time | ~15 minutes | 30-90 minutes |
| Dependencies | None | nginx, Postgres, app runtime |
| TLS management | Automatic (certmagic) | certbot or Caddy needed |
| Write concurrency | Single writer (SQLite) | Multi-writer (Postgres) |
| Data portability | Single directory copy | pg_dump / restore |
| Horizontal scaling | Not possible (SQLite) | Possible with connection pooling |
| Custom business logic | JS/Go hooks, limited | Full application code |
| Upgrade path | Replace binary | Coordinated deploy |
PocketBase is the wrong tool when you need concurrent writes at scale, complex transactions across multiple tables, or full-text search with relevance ranking. It is the right tool for prototypes, internal tools, small APIs, and projects where one person manages the server.
Upgrading Is One Command
No migration scripts, no dependency dance, no deploy pipeline. PocketBase upgrades are binary replacements -- stop, swap, restart:
systemctl stop pocketbase.service
wget https://github.com/pocketbase/pocketbase/releases/download/vX.Y.Z/pocketbase_X.Y.Z_linux_amd64.zip -P /tmp/
unzip /tmp/pocketbase_X.Y.Z_linux_amd64.zip -d /tmp/pb-new/
chmod +x /tmp/pb-new/pocketbase
mv /root/pb/pocketbase /root/pb/pocketbase.bak
mv /tmp/pb-new/pocketbase /root/pb/pocketbase
systemctl start pocketbase.service
systemctl status pocketbase.service
Check logs after restart:
tail -f /root/pb/errors.log
PocketBase runs automatic schema migrations on startup. Keep pocketbase.bak until you confirm the new version is healthy -- rolling back is another binary swap.