You don't need a Kubernetes cluster to track production errors
Sentry is a great product. Sentry's self-hosted distribution is also a great product — and a 20-service Docker Compose stack with substantial CPU, RAM, and disk needs. If your team's main interaction with self-hosted observability is a Slack thread that begins the Sentry box is OOMing again
, you're not alone.
BugSink takes the opposite approach. One application container, one Postgres database, Sentry-SDK-compatible. You point your existing Sentry SDKs at it by changing the DSN. Per BugSink's own marketing page, it's more lightweight and less complicated than Sentry
— and the install proves it.
This tutorial wires BugSink onto a $5 VPS with Docker, Postgres, HTTPS via Nginx, and Git-based auto-deploys from DeployHQ. By the end, every change to the stack — env vars, container versions, Nginx config — ships from a git push, with one-click rollback in DeployHQ if a tweak breaks something.
What you'll build
- BugSink (current 2.x major version) as a single application container
- Postgres 17 in another container for persistent storage of issues, events, and project config
- Docker Compose to glue them together with persistent volumes and restart policies
- Nginx + Let's Encrypt for HTTPS termination in front of BugSink
- DeployHQ for Git-based auto-deploys so the whole stack lives in version control
- A working DSN you can drop into any Sentry SDK to redirect events to your VPS
Why BugSink instead of Sentry self-hosted? Three reasons that come up in real conversations. Cost predictability — BugSink is free to self-host with unlimited users and events; you pay for the VPS, full stop. Resource footprint — one app + one DB versus Sentry's ~20-service Helm/Compose distribution. Data residency — for teams who need stack traces (and the source context Sentry sends with them) to stay on infrastructure they own, BugSink is much easier to keep audit-ready.
Why a VPS instead of a hosted plan? If volume is low (handful of apps, hobby projects) the VPS is genuinely cheaper. If volume is high, you're paying once for a box you control instead of per-event metering forever. Either way, a $5/month VPS at Hetzner, DigitalOcean, Vultr, or Linode is enough to start.
Prerequisites
- A VPS running Ubuntu 22.04 or 24.04 with SSH access
- A domain name pointing at the VPS (e.g.
bugsink.example.com) - A GitHub account with an empty private repo for the deploy config
- A DeployHQ account (free trial — sign-up link at the end)
- Docker and Docker Compose installed locally for testing
Step 1: Prepare the VPS
SSH in as root and install Docker:
ssh root@your-vps-ip
curl -fsSL https://get.docker.com | sh
Create a non-root deploy user that DeployHQ will SSH in as:
adduser deploy
usermod -aG docker deploy
mkdir -p /home/deploy/bugsink
chown deploy:deploy /home/deploy/bugsink
Copy your SSH key to the new user from your local machine so DeployHQ can authenticate without a password:
ssh-copy-id deploy@your-vps-ip
Confirm ssh deploy@your-vps-ip works passwordless. If it works for you, DeployHQ can do the same.
Step 2: The Docker Compose stack
BugSink ships an official production sample at github.com/bugsink/bugsink/blob/main/docker-compose-sample.yaml. Use it as the starting point, then tighten the production knobs.
In your fresh GitHub repo, create compose.yaml:
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: bugsinkuser
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: bugsink
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -h db
retries: 5
start_period: 10s
interval: 5s
timeout: 5s
web:
image: bugsink/bugsink:2
depends_on:
db:
condition: service_healthy
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000"
environment:
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://bugsinkuser:${POSTGRES_PASSWORD}@db:5432/bugsink
PORT: 8000
BEHIND_HTTPS_PROXY: "true"
USE_X_FORWARDED_HOST: "true"
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8000/\").raise_for_status()'"]
interval: 5s
timeout: 20s
retries: 10
deploy:
resources:
limits:
memory: 1G
cpus: "1.0"
volumes:
db-data:
Three changes from BugSink's sample worth flagging:
SECRET_KEYandPOSTGRES_PASSWORDare read from environment, not baked into the file. We'll inject them from DeployHQ as a config file in the next step. Never commit secrets to the repo.- Port 8000 is bound to
127.0.0.1, not0.0.0.0. BugSink should not be exposed directly to the public internet — Nginx will handle that. Bind to localhost; firewall the external port at the host level too. BEHIND_HTTPS_PROXYandUSE_X_FORWARDED_HOSTare bothtruebecause Nginx will terminate TLS and forwardX-Forwarded-Proto/X-Forwarded-Host. BugSink needs to know to trust those headers when generating absolute URLs (otherwise the DSN it shows you will usehttp://, and the SDK won't be able to connect).
Generate a real SECRET_KEY locally:
python -c "import secrets; print(secrets.token_urlsafe(50))"
And a Postgres password the same way. Save both — you'll paste them into DeployHQ in the next step.
Also add a .gitignore:
.env
*.local
Commit everything (the compose file, the gitignore, nothing else) and push to GitHub.
Step 3: Wire it up with DeployHQ
This is where the stack stops being a one-off SSH session and starts being a real deployment.
In DeployHQ:
- Create a new project and point it at your GitHub repo. DeployHQ supports deploying directly from a GitHub repo to your server without manual webhook setup.
- Add a server. Use the VPS IP, port 22, username
deploy, and the SSH key you authorised in Step 1. Set the deployment path to/home/deploy/bugsink. - Add a config file at
/home/deploy/bugsink/.envcontaining:SECRET_KEY=<the value you generated> POSTGRES_PASSWORD=<the password you generated>DeployHQ writes this file to disk before every deploy, so Compose picks it up — and the secrets never live in your repo or your container image. - Configure the SSH command. Under the server's SSH commands, add:
bash cd /home/deploy/bugsink && docker compose up -dThat's it. DeployHQ checks out the repo, writes the env file, SSHes in, and runs Compose. New container versions, env tweaks, resource limit changes — all of them ship via the same one-line deploy. - Enable auto-deploy on push to
main. Every merge now triggers a build.
Trigger the first deploy manually. Watch the log: clone, write env, run Compose. About 30 seconds later, BugSink and Postgres are both up. Check with docker compose ps on the VPS — both services should be healthy.
If anything breaks at this stage, DeployHQ's one-click rollback restores the previous deploy in seconds, so you can iterate on config without leaving the box in a half-broken state.
Step 4: HTTPS with Nginx and Let's Encrypt
BugSink is now running on 127.0.0.1:8000. To put it on the internet at bugsink.example.com with HTTPS:
On the VPS (still as root):
apt update && apt install -y nginx certbot python3-certbot-nginx
Create /etc/nginx/sites-available/bugsink:
server {
listen 80;
server_name bugsink.example.com;
client_max_body_size 25M;
location / {
proxy_pass http://127.0.0.1:8000;
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;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s;
}
}
Two notes. client_max_body_size 25M — SDK event payloads can be sizeable (source context, attachments) and the default 1MB will reject them. X-Forwarded-Proto and X-Forwarded-Host are why we set BEHIND_HTTPS_PROXY and USE_X_FORWARDED_HOST in Compose; the two halves have to agree.
Enable the site and get a cert:
ln -s /etc/nginx/sites-available/bugsink /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d bugsink.example.com
certbot handles cert provisioning, the redirect from HTTP to HTTPS, and auto-renewal via systemd timers. After this, https://bugsink.example.com/ returns the BugSink login screen.
Step 5: First login and creating a project
The first time you visit your BugSink instance you'll need a superuser. The cleanest way to create one is a one-off manage.py invocation against the running container:
docker compose exec web python -m bugsink.manage createsuperuser
You'll be prompted for email, username, and password. The official docker-compose-sample.yaml also supports a CREATE_SUPERUSER: email:password env var for the very first boot, but injecting a password through env is messy; the createsuperuser command keeps the secret off your disk history.
Log in. Create a new project (one per application is the standard pattern — same as Sentry). BugSink shows you the DSN on the project page; it looks like a normal Sentry DSN, just pointing at your VPS hostname instead of o<your-org>.ingest.sentry.io.
Step 6: Point an application at BugSink
The whole point of Sentry-SDK compatibility: nothing in your application code changes.
In the SDK init for a Python app, that's:
import sentry_sdk
sentry_sdk.init(
dsn="https://<your-bugsink-key>@bugsink.example.com/<project-id>",
environment="production",
release="my-app@1.2.3",
)
Same for JavaScript, Ruby, PHP, Go, .NET, Rust — every Sentry SDK that takes a DSN takes BugSink's. Per BugSink's own quickstart docs, install an SDK, copy the DSN, trigger an error on purpose, and it appears in BugSink within seconds.
Two things to know:
releaseis the same field Sentry uses, and BugSink's release tracking works the same way — issues get tagged with the release they first appeared in. Thereleasestring should be a version + commit SHA your build pipeline produces, not a timestamp.environmenthas to match between your SDK config and any release notifications you send (more on that below).prodvsproductionmismatches will silently break attribution.
Production hardening
A working deploy is the floor, not the ceiling. Five things to add before BugSink starts taking real traffic:
1. Pin the image by digest, not the floating :2 tag. BugSink's :2 tag is the current major-version label, and the maintainer pushes patch updates to it. For reproducible builds, pin to a specific digest (bugsink/bugsink@sha256:...) and bump it explicitly via a commit. Same for postgres:17-alpine — pin the minor.
2. Back up the Postgres volume. Stack traces are not infinite — BugSink has retention policies — but losing your historical issue grouping mid-incident is painful. A daily pg_dump to S3 (or another off-host destination) via a cron container is the minimum bar. The DB volume in the compose above is what survives container restarts; without backups it does not survive a VPS disk failure.
3. Watch the ingest rate limit. Per BugSink's ingestion rate limit docs, there are project-level limits to keep one noisy app from drowning out the rest. Configure these before an app starts throwing 500 errors at 100/sec.
4. Resource limits on the web container. The compose above already includes a 1G memory / 1 CPU cap on the web service. Tune up if you're ingesting heavy traffic — BugSink's event digestion isn't free, and unbounded growth on a $5 VPS will OOM the whole box (including Postgres).
5. Rate-limit the ingest endpoint at Nginx. BugSink will rate-limit at the application layer per the link above, but a Nginx limit_req zone on /api/ (or wherever the SDK posts events) acts as a cheap pre-filter against accidental loops in misconfigured clients.
For the deploys themselves, DeployHQ runs zero downtime deployments by default. When you graduate to multiple replicas behind a load balancer or move BugSink to a bigger box, the rollout pattern stays the same.
Tying BugSink issues to DeployHQ deploys
Because BugSink speaks the Sentry release API, the existing DeployHQ Sentry deploy tracking integration can in principle point at a BugSink installation — same project:releases scope, same release-creation endpoint shape. If your team relied on Sentry's release-tagged regressions before, you can keep that workflow with BugSink as the backend. (Test before relying on it in production — Sentry SDK compatibility is BugSink's headline claim, but the management API surface has edges; verify against your specific version.)
If you'd rather not test that route, the same outcome falls out of including a release value in every SDK init that matches the commit SHA your build pipeline already exposes. BugSink groups issues by release automatically.
Where to go from here
The setup above is a working production install for a single low-to-medium-traffic team. A few paths from here, depending on what you actually need next:
More self-hosted observability on the same VPS pattern. The same Docker Compose + Nginx + DeployHQ pattern fits a long list of self-hostable tools:
- Hermes Agent on a VPS — self-improving agent runtime with persistent skills
- Self-host the Paperclip orchestrator on a VPS — agent orchestrator with a Docker-first install
- Self-host Immich on a VPS — open-source Google Photos alternative
- Self-host Vaultwarden on a VPS — open-source Bitwarden server
- Deploy your first AI agent to a VPS with Docker — minimal FastAPI agent in the same shape
Or stick with hosted error tracking and add deploy tracking. If self-hosting is one bridge too far and you'd rather stay on Sentry, Bugsnag, Rollbar, or Honeybadger, the DeployHQ integration pattern is identical to what you just built — one config block in DeployHQ, every release shows up in the dashboard:
- Sentry deploy tracking with DeployHQ (see the integration linked above)
- Bugsnag deploy tracking with DeployHQ
- Rollbar deploy tracking with DeployHQ
- Honeybadger deploy tracking with DeployHQ
The point isn't which backend you pick — it's that error tracking, the deploy that introduced each error, and a one-click rollback should all live in the same workflow. BugSink + DeployHQ gets you there for the price of a VPS.
Ready to put error tracking on infrastructure you control? Start a free DeployHQ trial and have BugSink shipping from git push in under thirty minutes.
Need a hand? Email us at support@deployhq.com or find us on X at @deployhq.