Context
Every once in a while I have to stand up a new backend/DB. I used to just use Supabase, but I don't like the idea of not owning my data, and I like my bill being the same number every month.
I personally love Pocketbase for this. It's ✨one binary✨ with a database (SQLite), auth, file storage, a REST API and an admin dashboard baked in. No install wizard, no 14 microservices, no YAML cathedral. It's what I reach for whenever a project needs a small, self-contained backend. If you want to compare other options, here are some other alternatives you could check out.
Challenge
I've now set this up for a handful of projects, and every single time I had to rediscover the steps like a goldfish with a keyboard. So this is the cheat sheet.
What we're building
By the end, requests will flow like this:
client (any app that calls your backend) → Reverse proxy tool (nginx) (HTTPS, on a server) → Docker container (PocketBase) → volume (your database)
The game plan (Agile style)
- Run PocketBase in a local dev environment
- Put it behind nginx
- Containerize it and keep it behind the local nginx
- Put nginx and the container on a VPS (remote env), with a domain and HTTPS
- Introduce a container registry for versioning our image (in our case GHCR)
- Automate the deploys with GitHub Actions or another CI/CD tool
What you'll need
Here's the stack I'm using, and what each piece is called in case you want to swap in your own:
- A backend/database: I'll be using PocketBase, an open source backend in a single executable with SQLite built in
- A container platform: I'm using Docker (and Docker Compose) because it's what I like to use the most
- A reverse proxy: it routes incoming requests to the right app, handles HTTPS, and enforces limits. I'll be using nginx
- Version control: Git, hosted on GitHub, because I like GitHub
- A container registry: a place to store our built Docker images. I use GHCR (GitHub Container Registry)
- A CI/CD tool: continuous integration/continuous delivery, the thing that builds and deploys for you on every push. I use GitHub Actions
- A remote environment: a VPS (virtual private server) to run it all on. I love DigitalOcean droplets
- A domain/subdomain for making the DB accessible on the internet
- Optional A health check monitor: a service that keeps pinging PocketBase's health endpoint and alerts you (or triggers a rollback) when it stops responding
and last but not least, a handy dandy terminal with basic knowledge of SSH.
BONUS: some music to help you lock in and time yourself. If you don't finish this tutorial within an hour, I'll personally refund you.
Everything in angle brackets like <username> or <website-url> is a placeholder for your own value.
1. PocketBase, locally
Nothing fancy yet. We want PocketBase running on your machine so you know what we're about to ship. Here's how:
# grab the binary for your OS/arch from https://github.com/pocketbase/pocketbase/releases
# (put it in a `pocketbase/` folder in your project)
cd pocketbase
# run the migrations, then start the server
PB_ENV=dev ./pocketbase migrate up
PB_ENV=dev ./pocketbase serve
# create yourself a superuser
./pocketbase superuser upsert dev-admin@example.com change-me-locally
If you're on Windows that's pocketbase.exe.
The admin dashboard lives at http://127.0.0.1:8090/_/.
You don't actually need PB_ENV (it isn't a PocketBase thing, just an environment variable I made up) or cross-env (a tiny tool that makes PB_ENV=dev work in any shell, Windows included), but they're useful for one very specific thing. Locally I want a user to log in with so I'm not creating one by hand every time I wipe my database. But I definitely don't want that user existing in production. So let's make a migration that only runs when PB_ENV=dev.
./pocketbase migrate create dev_user
// pb_migrations/<timestamp>_dev_user.js
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// bail out unless we are in dev
if ($os.getenv("PB_ENV") !== "dev") return;
let existing;
const DEV_EMAIL = "dev@example.com";
const DEV_PASS = "change-me-locally";
try {
existing = app.findFirstRecordByData("users", "email", DEV_EMAIL);
} catch (e) {
existing = null;
}
if (existing) return;
const record = new Record(app.findCollectionByNameOrId("users"));
record.set("email", DEV_EMAIL);
record.set("password", DEV_PASS);
record.set("verified", true);
app.save(record);
}, (app) => {
// same guard on the way down
if ($os.getenv("PB_ENV") !== "dev") return;
let existing;
try {
existing = app.findFirstRecordByData("users", "email", "dev@example.com");
} catch (e) {
existing = null;
}
if (existing) app.delete(existing);
})
That PB_ENV check is the whole trick. In production the migration runs, shrugs, and does nothing.
Here's what we've achieved so far:
- ✅ PocketBase running locally
- ✅ a superuser and a dev-only user
- ✅ migrations that know which environment they're in
2. Put it behind nginx
Right now you're talking to PocketBase directly on port 8090. That's fine on your laptop, but it's not what you want facing the internet. Before we touch the server, let's get used to the setup we'll use there.
What is nginx doing here? It's a reverse proxy. It sits in front of PocketBase, accepts the incoming requests, and forwards them to it. That gives us a few things:
- HTTPS. nginx handles the TLS certificate so PocketBase doesn't have to
- One front door for many apps. Several apps can share one server and one port 443, routed by domain name
- Guard rails. Rate limits and a max upload size, before a request ever reaches PocketBase
- Privacy. The app itself only listens on localhost and can't be reached directly from the internet
What else could you use?
- Caddy is simpler, and gets and renews HTTPS certificates automatically
- Traefik is built for containers and configures itself from Docker labels
- PocketBase can also serve HTTPS by itself if you hand it a domain, which is the least moving parts if it's the only thing on the box
Why nginx then? For me it's familiarity and it gives me everything I need at the moment out of the box; Rate limiting and upload limits in one config file. If you're starting from scratch with a single app, Caddy is a perfectly good answer as well.
Install nginx (brew install nginx, sudo apt install nginx, or the zip from nginx.org on Windows) and add this server block to your config. Where it goes depends on your OS: conf.d/, sites-enabled/, or inside http {} in nginx.conf.
# local dev: nginx listens on 8080 and forwards to PocketBase on 8090
server {
listen 8080;
server_name localhost;
# max upload size, i.e. maxSelect:20 * maxSize:10MB per request
client_max_body_size 250m;
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;
location / {
proxy_pass http://127.0.0.1:8090;
}
}
nginx -t # check the config
nginx -s reload # (or start nginx if it isn't running yet)
Now http://localhost:8080/_/ shows the same dashboard, but through nginx. If you plan to use PocketBase's realtime subscriptions, double check PocketBase's docs for the extra nginx proxy settings they recommend.
Later we'll add a rate limit for any collection that strangers are allowed to write to. For now, one thing to take away: nginx doesn't care what's behind it, as long as something answers on the port you give it. Which brings us to the next step.
3. Put PocketBase in Docker Compose, behind the same nginx
Now we put PocketBase in a box, a container, 🐳
What does Docker give us? It's a container platform: it packages an app and everything it needs into an image, and runs that image as an isolated container. That makes it easy to set up the same environment across platforms/machines with a single command. To update you just pull the latest image, rollback is the same with an older image, and your data stays safe in a volume.
What else could you use?
- Run the binary directly under
systemd. Fewer layers, totally valid for a hobby project, but you're updating and wiring it up by hand every time - Podman, a Docker-compatible alternative that runs without a background daemon
- Kubernetes (or a lighter orchestrator like Docker Swarm or Nomad). They run and scale containers across many servers, which is way more than one small PocketBase on one box needs, but good to know they exist for when you outgrow this
- A managed PocketBase host, or a PaaS with persistent disks, if you'd rather not run a server at all
Here's the Dockerfile. Notice we only copy in pb_hooks and pb_migrations, the code, not the data. Your actual database lives in a volume.
# pocketbase/Dockerfile
FROM alpine:3.20
# pin whatever version you tested against, bump it on purpose
ARG PB_VERSION=0.40.2
RUN apk add --no-cache unzip ca-certificates curl
WORKDIR /pb
RUN curl -L -o pb.zip https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
&& unzip pb.zip -d /pb/ \
&& rm pb.zip \
&& chmod +x /pb/pocketbase
COPY pb_hooks /pb/pb_hooks
COPY pb_migrations /pb/pb_migrations
EXPOSE 8090
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
Heads up, those two COPY lines will fail the build if the folders don't exist. That's why running migrate up locally first matters, it makes sure pb_migrations/ is there so the image can actually be built.
And the compose file:
# pocketbase/docker-compose.yml
services:
pocketbase:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:8091:8090"
environment:
- PB_ENV=${PB_ENV:-production}
volumes:
- pb_data:/pb/pb_data
volumes:
pb_data:
A few small details doing big work here:
-
127.0.0.1:8091:8090reads ashost-ip:host-port:container-port, and the two port numbers are different on purpose so you can see which is which:- 8090 is the port PocketBase listens on inside the container. It's fixed by the Dockerfile and nobody outside the container can see it
-
8091 is the port Docker listens on on the machine itself (the host). It passes whatever arrives there into the container's 8090. This is the port nginx forwards to, that's the
proxy_pass. The public internet never calls 8091, it talks to nginx - 8090 is the default and you could have both of them ast 8090 or use any free port.
- That
127.0.0.1(instead of0.0.0.0) means the container is only reachable through nginx and not directly from the internet -
PB_ENV=${PB_ENV:-production}defaults to production, so you have to ask for dev on purpose. Our dev-only migration stays asleep unless you say otherwise - the
pb_datavolume, so recreating the container doesn't delete your database
You can stop the binary from step 1 now (Ctrl+C). Start the container in dev mode, then point nginx at the new port by changing proxy_pass to http://127.0.0.1:8091 and reloading:
cd pocketbase
PB_ENV=dev docker compose up -d --build
# PowerShell: $env:PB_ENV="dev"; docker compose up -d --build
nginx -s reload
http://localhost:8080/_/ works again, same nginx, different thing behind it. The container starts with an empty database, so run the superuser command again:
docker compose exec pocketbase /pb/pocketbase superuser upsert dev-admin@example.com change-me-locally
Here's what we've achieved so far:
- ✅ PocketBase running locally, directly and in a container
- ✅ nginx in front of it
4. Put it on a VPS
Same two pieces, nginx and the compose file, but this time on a server on the internet.
Why a VPS? I get a real Linux box with full control, a flat monthly price, and a place for several small apps. PocketBase wants a persistent disk, and a VPS has one.
Any provider works. A few to look at:
- DigitalOcean (Droplets)
- AWS (Lightsail or EC2)
- Microsoft Azure (Virtual Machines)
- Google Cloud (Compute Engine)
- Hetzner (Cloud servers)
Here's the order, and the order matters:
DNS first
Add an A record pointing your subdomain (api.yourdomain.com, or anything you like) at the server's IP. DNS takes a while to propagate and we need it ready for HTTPS, so do it now and do the rest while it settles.
Prep the server
Log in as root and install what we need (Debian/Ubuntu shown, Docker via the install guide):
apt update && apt install -y nginx certbot python3-certbot-nginx
Then create a dedicated user for deploys, don't deploy as root:
sudo adduser <username>
sudo mkdir -p /opt/<dirname>
sudo chown -R <username>:<username> /opt/<dirname>
# the user needs docker access
sudo usermod -aG docker <username>
# somewhere for the deploy key to live
sudo -u <username> mkdir -p -m 700 /home/<username>/.ssh
And not on the server, on your own machine, make a deploy key and give the server the public half:
# make a dedicated deploy key
ssh-keygen -t ed25519 -C <username> -f ~/.ssh/<keyname> -N "<passphrase>"
# log in as root once and copy the public key over
ssh root@<vps-ip> "cat >> /home/<username>/.ssh/authorized_keys" < ~/.ssh/<keyname>.pub
# or, if you want to prepend restrictions to the key
sed 's/^/no-port-forwarding,no-agent-forwarding /' ~/.ssh/<keyname>.pub | ssh root@<vps-ip> "cat >> /home/<username>/.ssh/authorized_keys"
# make sure the new user can actually log in
ssh <username>@<vps-ip> -i ~/.ssh/<keyname>
A dedicated deploy key is cleaner than reusing your personal one, if it leaks you revoke that one key and nothing else.
Get the files there and start it
For now we'll build the image right on the server. Run this from your pocketbase/ folder (we're sending the code, not your local data):
scp -r Dockerfile docker-compose.yml pb_hooks pb_migrations <username>@<vps-ip>:/opt/<dirname>/
ssh <username>@<vps-ip> -i ~/.ssh/<keyname> "cd /opt/<dirname> && docker compose up -d --build"
No PB_ENV this time, so it defaults to production and the dev user is never created.
The server's nginx
Same idea as before, with three upgrades: your real domain, the production port, and a rate limit.
# throttle public write endpoints to curb spam
limit_req_zone $binary_remote_addr zone=<zone-name>:10m rate=5r/m;
server {
listen 80;
server_name <website-url>;
# max upload size, i.e. maxSelect:20 * maxSize:10MB per request
client_max_body_size 250m;
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;
# any collection with a public (unauthenticated) createRule
location = /api/collections/<collection>/records {
limit_req zone=<zone-name> burst=3 nodelay;
proxy_pass http://127.0.0.1:8091;
}
location / {
proxy_pass http://127.0.0.1:8091;
}
}
That limit_req block is for any collection where you let strangers create records, like a contact form. If the rule is public, the internet will find it. Rate limit it.
Copy it over, enable it, and issue a free HTTPS certificate. Writing to /etc/nginx and running certbot needs root, so use your root login here. Your deploy user shouldn't need that power, and I'd keep it that way.
scp nginx-<website-url>.conf root@<vps-ip>:/etc/nginx/sites-available/<website-url>
ssh root@<vps-ip> "ln -s /etc/nginx/sites-available/<website-url> /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx"
# once DNS has propagated
ssh root@<vps-ip> "certbot --nginx -d <website-url>"
Certbot rewrites the site file in place and adds the 443 block plus an 80 → 443 redirect.
Your production superuser
The dev account was never created here (thanks PB_ENV), so nobody can log in yet:
ssh <username>@<vps-ip> -i ~/.ssh/<keyname>
cd /opt/<dirname>
# confirm the container is running
docker compose ps
# create the real superuser with a real password
docker compose exec pocketbase /pb/pocketbase superuser upsert <superuser-email> <strong-password>
Visit https://<website-url>/_/ and log in.
Here's what we've achieved so far:
- ✅ PocketBase running on a server, in a container, only reachable through nginx
- ✅ a deploy user with its own key
- ✅ a real superuser and no dev account in production
5. A container registry
Right now the server builds the image itself. It works, but every deploy means sending files over and building on a small machine, and there's no clean record of which version is running.
What's a container registry? It's a place to store built Docker images, with tags. Instead of building on the server, we build once somewhere else, push the image to the registry with a version tag, and the server just pulls that exact image. That gets us:
- Faster, lighter deploys. The server only downloads, it never builds
- Versioning. We tag each image with the git commit that produced it, so you always know what's running and can roll back by pulling an older tag
- The same image everywhere. What you tested is what you deploy
What else could you use?
- Docker Hub, the default and the most common, but anonymous pulls are rate limited
- A cloud provider's own registry (AWS ECR, DigitalOcean Container Registry, and so on), good if you already live there, but another service to set up and pay attention to
- Running your own registry, which is one more server to look after
Why GHCR? GitHub Container Registry (ghcr.io) lives right next to the code. Our deploy will run on GitHub Actions anyway, and Actions can log in to GHCR with the token it already has, no extra account and no extra secret to manage.
Your compose file changes in one place: swap build: . for an image: line. This is the file that lives on the server from now on:
# pocketbase/docker-compose.yml
services:
pocketbase:
image: ghcr.io/<github-user>/<dirname>:${IMAGE_TAG:-latest}
restart: unless-stopped
ports:
- "127.0.0.1:8091:8090"
environment:
- PB_ENV=production
volumes:
- pb_data:/pb/pb_data
volumes:
pb_data:
Two notes. Image names on GHCR have to be lowercase. And ${IMAGE_TAG:-latest} means "use this tag, or latest if I didn't give one", which is how a deploy picks a specific version.
6. Automate it with GitHub Actions
You could build and push by hand, but we've built all this so we can just push to main and let the computer handle it automatically (AI, ladies and gentlemen).
Here's the whole pipeline at a glance:
First, give GitHub the key it needs. In your repo settings, add these as Actions secrets:
-
VPS_HOST= the VPS's IP -
VPS_USER=<username> -
VPS_SSH_KEY= the private key that matches the authorized key on the VPS -
VPS_SSH_PASSPHRASE= the passphrase, only if you set one
Then the workflow. It builds the image, pushes it to GHCR, copies the compose file to the server, pulls and restarts, and waits for PocketBase to say it's healthy.
# .github/workflows/deploy-pocketbase.yml
name: Deploy PocketBase
on:
push:
branches: [main]
paths:
- "pocketbase/**"
- ".github/workflows/deploy-pocketbase.yml"
workflow_dispatch: {}
permissions:
contents: read
packages: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: ./pocketbase
file: ./pocketbase/Dockerfile
push: true
tags: |
ghcr.io/<github-user>/<dirname>:${{ github.sha }}
ghcr.io/<github-user>/<dirname>:latest
- name: Copy compose file to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: "pocketbase/docker-compose.yml"
target: "/opt/<dirname>/"
strip_components: 1
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
env:
GHCR_ACTOR: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IMAGE_TAG: ${{ github.sha }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: GHCR_ACTOR,GHCR_TOKEN,IMAGE_TAG
script: |
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_ACTOR" --password-stdin
cd /opt/<dirname>
docker compose pull pocketbase
docker compose up -d
- name: Health check
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8091/api/health > /dev/null 2>&1; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i/30 - waiting..."
sleep 2
done
echo "Health check failed after 30 attempts"
exit 1
Because of workflow_dispatch, you can also trigger it by hand from the Actions tab, no merging required. Very handy for the "I changed nothing, please just try again" moments.
Push to main and watch the Actions tab do its thing. Every deploy is now tagged with its commit, and that's your rollback button.
Finishing touches
Point your frontend at it. For me that's an environment variable in Vercel:
NEXT_PUBLIC_POCKETBASE_URL=https://<website-url>
Redeploy the frontend, and that's it.
For the days when something is off, here's my "turn it off and on again" toolkit:
ssh <username>@<vps-ip> -i ~/.ssh/<keyname>
cd /opt/<dirname>
docker compose ps # status
docker compose logs -f # tail the logs
docker compose up -d # start/restart (does NOT pull a new image)
docker compose pull && docker compose up -d # get the latest image, then restart
docker compose exec pocketbase sh # shell into the running container
What I left out, because it's beyond a one hour setup but you'll want it soon: backups of the pb_data volume, a firewall that only allows SSH, HTTP and HTTPS, and some kind of uptime monitoring.
Here's the whole journey:
- ✅ PocketBase running locally with a dev-only user
- ✅ nginx in front of it, and why
- ✅ a container that ships your code and keeps your data in a volume
- ✅ a VPS with a domain, HTTPS and rate limiting
- ✅ a registry so every deploy has a version
- ✅ a pipeline that deploys on every push to
main
And there you have it! This was how to deploy your own PocketBase backend to a remote environment, in less than an hour (or something like that).


Top comments (0)