Install Bugsink on Google Cloud Platform

Bugsink is a lightweight self-hosted error tracking service written in Python/Django. It’s compatible with Sentry Client SDK, so migration means just changing a DSN URL.

Bugsink can be sensibly scaled according to usage. In my case, I went with minimal configuration - a single gunicorn process. I tested the setup, and it’s fast enough for my needs. On the server it takes little less than 200MB.

The deployment is semi-automatic, because I need to update DNS records for the newly created VM, and enter login/password for the superuser.

Deployment takes less than 10 minutes. At which point I can sign in, create a project and copy the DSN URL to plug into my service.

I’ve split it into two files:

  1. Create a virtual machine and set up firewall rules.

  2. Provisioning script that runs on the newly created VM as root.

scripts/create_bugsink.sh:

#!/usr/bin/env bash

set -eo pipefail

PROJECT=demindev
ACCOUNT=119300227062
USERNAME=peterdemin
PUBKEY=$(ssh-keygen -yf ~/.ssh/id_rsa)
INSTANCE=bugsink
DOMAIN=bugsink.demin.dev

gcloud compute instances create $INSTANCE \
    --project=$PROJECT \
    --zone=us-west1-c \
    --machine-type=e2-micro \
    --network-interface=network-tier=STANDARD,stack-type=IPV4_ONLY,subnet=default \
    --tags=http-server,https-server \
    --public-ptr \
    --public-ptr-domain="${DOMAIN}." \
    --metadata="ssh-keys=${USERNAME}:${PUBKEY}" \
    --maintenance-policy=MIGRATE \
    --provisioning-model=STANDARD \
    --service-account="${ACCOUNT}-compute@developer.gserviceaccount.com" \
    --create-disk=auto-delete=yes,boot=yes,device-name=$INSTANCE,image=projects/debian-cloud/global/images/debian-13-trixie-v20251014,mode=rw,size=10,type=pd-standard \
    --no-shielded-secure-boot \
    --shielded-vtpm \
    --shielded-integrity-monitoring \
    --labels=goog-ec-src=vm_add-gcloud \
    --reservation-affinity=any

echo "Attempting to set up firewall rules, it's okay to fail if they already exist"

gcloud compute firewall-rules create default-allow-http \
    --project=$PROJECT \
    --direction=INGRESS \
    --priority=1000 \
    --network=default \
    --action=ALLOW \
    --rules=tcp:80 \
    --source-ranges=0.0.0.0/0 \
    --target-tags=http-server \
    || true
gcloud compute firewall-rules create default-allow-https \
    --project=$PROJECT \
    --direction=INGRESS \
    --priority=1000 \
    --network=default \
    --action=ALLOW \
    --rules=tcp:443 \
    --source-ranges=0.0.0.0/0 \
    --target-tags=https-server \
    || true

echo "Update DNS to point bugsink subdomain to the EXTERNAL IP address"

ssh-keygen -R $DOMAIN || true
until ssh $DOMAIN 'echo up'; do sleep 1; done

scp -rC scripts/provision_bugsink.sh "${DOMAIN}:"
ssh -t $DOMAIN -- "chmod +x provision_bugsink.sh && sudo ./provision_bugsink.sh"

scripts/provision_bugsink.sh:

#!/usr/bin/env bash
set -euo pipefail

# Bugsink single-server production installer (Ubuntu 24.04-style)
# Docs: https://www.bugsink.com/docs/single-server-production/

HOST=bugsink.demin.dev
EMAIL=peter@demin.dev

if [[ $EUID -ne 0 ]]; then
  echo "ERROR: Run this script as root (sudo -i)."
  exit 1
fi

log() { printf "\n[%s] %s\n" "$(date -Is)" "$*"; }

run_as_bugsink() {
  # Run a command as the bugsink user, in /home/bugsink, with bash -lc so venv activation works.
  su - bugsink -c "bash -lc 'cd /home/bugsink && $*'"
}

write_file_root() {
  # write_file_root /path/to/file "content..."
  local path="$1"
  shift
  install -d -m 0755 "$(dirname "$path")"
  cat >"$path" <<'EOF'
EOF
  # Replace file content with provided stdin after this function returns? Not needed.
}

apt-mark hold google-cloud-cli google-cloud-cli-anthoscli google-guest-agent google-osconfig-agent

mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg > /usr/share/keyrings/tailscale-archive-keyring.gpg
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list > /etc/apt/sources.list.d/tailscale.list

log "Install packages"
export DEBIAN_FRONTEND=noninteractive
apt update
apt -y upgrade
apt -y install      \
    python3         \
    python3-pip     \
    python3-venv    \
    nginx           \
    curl            \
    tailscale

log "Login to tailscale"
echo "Log in to your Tailscale account to add a new instance. Then disable SSH access"
tailscale login

log "Create non-root user: bugsink (if missing)"
if ! id -u bugsink >/dev/null 2>&1; then
  adduser bugsink --disabled-password --gecos ""
fi

log "Create/activate venv and install/upgrade Bugsink"
run_as_bugsink 'python3 -m venv venv'
run_as_bugsink '. venv/bin/activate && python3 -m pip install --upgrade pip'
run_as_bugsink '. venv/bin/activate && python3 -m pip install --upgrade bugsink'
run_as_bugsink '. venv/bin/activate && bugsink-show-version'

log "Generate production config: bugsink_conf.py (template=singleserver)"
# If config exists, we keep it (idempotent-ish)
if [[ ! -f /home/bugsink/bugsink_conf.py ]]; then
  run_as_bugsink ". venv/bin/activate && bugsink-create-conf --template=singleserver --host='${HOST}'"
else
  log "   /home/bugsink/bugsink_conf.py already exists; leaving it unchanged."
fi

log "Initialize DBs (migrate main + snappea queue DB)"
run_as_bugsink ". venv/bin/activate && bugsink-manage migrate"
run_as_bugsink ". venv/bin/activate && bugsink-manage migrate snappea --database=snappea"

log "Create the superuser"
echo
echo ">>> You will now be prompted to create the Bugsink superuser (per the docs)."
echo ">>> Use an email address as username if you like."
echo
run_as_bugsink ". venv/bin/activate && bugsink-manage createsuperuser"

log "Sanity checks"
run_as_bugsink ". venv/bin/activate && bugsink-manage check_migrations"
run_as_bugsink ". venv/bin/activate && bugsink-manage check --deploy --fail-level WARNING"

log "systemd: gunicorn.service"
cat >/etc/systemd/system/gunicorn.service <<EOF
[Unit]
Description=gunicorn daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=bugsink
Group=bugsink
Environment="PYTHONUNBUFFERED=1"
WorkingDirectory=/home/bugsink
UMask=0077

ExecStart=/home/bugsink/venv/bin/gunicorn \\
    --bind="127.0.0.1:8000" \\
    --workers=1 \\
    --timeout=60 \\
    --access-logfile - \\
    --max-requests=1000 \\
    --max-requests-jitter=100 \\
    bugsink.wsgi
ExecReload=/bin/kill -s HUP \$MAINPID
Restart=on-failure
RestartSec=3s
KillMode=mixed
TimeoutStartSec=30s
TimeoutStopSec=10s

# ---- Hardening Controls ----

# Prevent privilege escalation
NoNewPrivileges=yes

# Isolate filesystem
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only

# Allow writes only where needed
ReadWritePaths=/home/bugsink

# Kernel surface reduction
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes

# Process namespace cleanup
ProtectProc=invisible
ProcSubset=pid
RemoveIPC=yes

# Capabilities cleanup
CapabilityBoundingSet=
AmbientCapabilities=

# Syscall restrictions
SystemCallArchitectures=native
SystemCallFilter=@system-service @network-io
SystemCallErrorNumber=EPERM

# Network controls
IPAddressDeny=any
IPAddressAllow=127.0.0.1/8
IPAddressAllow=::1/128

# Limits
TasksMax=200
LimitNOFILE=65536

# Logging
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now gunicorn.service

log "nginx"
rm -f /etc/nginx/sites-enabled/default || true

cat >/etc/nginx/sites-available/bugsink <<EOF
upstream bugsink_upstream {
  server 127.0.0.1:8000;
  keepalive 32;
}

limit_req_zone \$binary_remote_addr zone=bugsink_req:10m rate=10r/s;
limit_conn_zone \$binary_remote_addr zone=bugsink_conn:10m;

server {
    server_name ${HOST};
    client_max_body_size 20M;
    access_log /var/log/nginx/bugsink.access.log;
    error_log /var/log/nginx/bugsink.error.log;
    limit_conn bugsink_conn 30;
    limit_req zone=bugsink_req burst=20 nodelay;

    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-Proto \$scheme;
        add_header Strict-Transport-Security "max-age=31536000; preload" always;
        proxy_redirect off;
    }
}
EOF

ln -sf /etc/nginx/sites-available/bugsink /etc/nginx/sites-enabled/bugsink
service nginx configtest
systemctl restart nginx.service

log "SSL via certbot"
if [ ! -e /usr/bin/certbot ]; then
    python3 -m venv /opt/certbot/
    /opt/certbot/bin/python3 -m pip install certbot certbot-nginx
    ln -s /opt/certbot/bin/certbot /usr/bin/certbot
fi
/opt/certbot/bin/certbot --agree-tos --nginx -m $EMAIL
install -m 0744 /dev/stdin /etc/cron.weekly/certbot-renew <<'EOF'
#!/bin/sh
certbot renew -q
EOF

service nginx configtest
systemctl restart nginx.service

log "systemd: snappea.service"
cat >/etc/systemd/system/snappea.service <<'EOF'
[Unit]
Description=Bugsink snappea worker
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=bugsink
Group=bugsink
WorkingDirectory=/home/bugsink

Environment="PYTHONUNBUFFERED=1"
Environment="PHONEHOME=False"
UMask=0077

ExecStart=/home/bugsink/venv/bin/bugsink-runsnappea

Restart=on-failure
RestartSec=3s
KillMode=mixed
TimeoutStartSec=30s
TimeoutStopSec=10s
RuntimeMaxSec=1d

# Hardening: privileges
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes

# Hardening: filesystem
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only

# Allow writes only where Bugsink needs them.
ReadWritePaths=/home/bugsink

# Hardening: kernel + proc
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RemoveIPC=yes

# Hardening: syscalls
SystemCallArchitectures=native
SystemCallFilter=@system-service @network-io
SystemCallErrorNumber=EPERM

# Network controls
IPAddressDeny=any
IPAddressAllow=127.0.0.1/8
IPAddressAllow=::1/128

# Resource limits
TasksMax=200
LimitNOFILE=65536

StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now snappea.service

# Remove Google cruft in background
screen -dm /bin/sh -c "apt remove --allow-change-held-packages -y google-cloud-cli google-cloud-cli-anthoscli google-guest-agent google-osconfig-agent"

echo
log "DONE. Memory usage:"
free -h
echo "Open: https://${HOST}"
echo
echo "Useful checks:"
echo "  systemctl status gunicorn.service"
echo "  systemctl status snappea.service"
echo "  journalctl -u snappea.service"
echo "  tail -n 200 /var/log/nginx/bugsink.error.log"

Once you’re done playing with it, here’s how you can destroy the VM:

#!/usr/bin/env bash

set -eo pipefail

PROJECT=demindev
INSTANCE=bugsink

gcloud compute instances delete "$INSTANCE" \
  --project=$PROJECT \
  --zone=us-west1-c \
  --quiet