River is a fast, transactional job queue for Go applications backed by PostgreSQL. Because it stores jobs in the same Postgres database your application already uses, jobs are enqueued atomically with your other writes and there is no separate Redis or RabbitMQ service to operate. This guide covers a production deployment of the three moving parts you actually run on a server: a hardened PostgreSQL instance, the River schema migrations, and the open-source River UI for observability, all sitting behind Caddy with automatic TLS.
River itself is a library that compiles into your own Go worker binary, so the "worker" portion of this guide uses a minimal example service. Swap in your real application binary and the operational scaffolding stays the same.
What you are deploying
| Component | Role | Port |
|---|---|---|
| PostgreSQL 16 | Job storage plus application data | 5432 (localhost only) |
| River CLI | Runs schema migrations | n/a |
| Worker binary | Your Go app that works jobs | n/a |
| River UI | Web interface for inspecting jobs and queues | 8080 (localhost only) |
| Caddy | Reverse proxy and TLS termination | 80, 443 |
Prerequisites
A RamNode KVM VPS with at least 2 GB RAM and 2 vCPU is comfortable for a moderate job volume. Postgres is the main memory consumer, so size up if you expect high throughput or large job payloads.
This guide assumes Ubuntu 24.04 LTS and a DNS A record (for example jobs.example.com) pointing at the VPS public IP before you start, since Caddy needs working DNS to issue a certificate. Commands run as a non-root sudo user.
1. Initial server preparation and hardening
Create a non-root user and apply baseline patches first.
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo apt update && sudo apt -y upgradeLock down SSH in /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication noThen sudo systemctl restart ssh. Make sure your key is installed for the deploy user before you disconnect.
Configure the firewall. Only SSH and the web ports face the internet. Postgres and the River UI bind to localhost only and are never opened.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableIf you prefer CSF, the equivalent is allowing TCP_IN = 22,80,443 and leaving 5432 and 8080 closed. Enable unattended security upgrades:
sudo apt -y install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades2. Install and harden PostgreSQL
sudo apt -y install postgresql postgresql-contrib
sudo systemctl enable --now postgresqlCreate the application database and a dedicated role:
sudo -u postgres psql <<'SQL'
CREATE ROLE riverapp WITH LOGIN PASSWORD 'CHANGE_ME_STRONG';
CREATE DATABASE riverdb OWNER riverapp;
SQLConfirm Postgres listens only on localhost. In /etc/postgresql/16/main/postgresql.conf:
listen_addresses = 'localhost'In /etc/postgresql/16/main/pg_hba.conf, require password auth over local TCP and remove any trust lines:
host riverdb riverapp 127.0.0.1/32 scram-sha-256Reload with sudo systemctl restart postgresql. Export a connection string you will reuse:
export DATABASE_URL="postgres://riverapp:CHANGE_ME_STRONG@127.0.0.1:5432/riverdb?sslmode=disable"sslmode=disable is acceptable here only because the connection never leaves the loopback interface. Do not use it for any remote connection.
3. Install Go and the River CLI
sudo apt -y install golang-go
go install github.com/riverqueue/river/cmd/river@latest
echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.bashrc && source ~/.bashrcRun the migrations that create River's tables and leader-election rows:
river migrate-up --database-url "$DATABASE_URL"This is also the command you rerun after a River version bump that ships new migration steps, so keep the CLI handy.
4. Build and run a worker as a service
In production the worker is your own Go binary that registers workers and starts a River client. A minimal stand-in looks like this (main.go):
package main
import (
"context"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/riverqueue/river"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
workers := river.NewWorkers()
// river.AddWorker(workers, &YourWorker{})
client, err := river.NewClient(riverpgxv5.New(pool), &river.Config{
Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: 100}},
Workers: workers,
})
if err != nil {
log.Fatal(err)
}
if err := client.Start(ctx); err != nil {
log.Fatal(err)
}
select {}
}Build it and place the binary in /opt/river-worker:
go build -o /opt/river-worker/worker .Run it under systemd as a dedicated unprivileged user with the connection string supplied through an environment file, never on the command line where it would show up in ps.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin riversvc
sudo install -d -o riversvc -g riversvc /opt/river-worker
sudo tee /etc/river-worker.env >/dev/null <<EOF
DATABASE_URL=postgres://riverapp:CHANGE_ME_STRONG@127.0.0.1:5432/riverdb?sslmode=disable
EOF
sudo chmod 600 /etc/river-worker.env/etc/systemd/system/river-worker.service:
[Unit]
Description=River worker
After=postgresql.service
Wants=postgresql.service
[Service]
User=riversvc
Group=riversvc
EnvironmentFile=/etc/river-worker.env
ExecStart=/opt/river-worker/worker
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now river-workerRiver shuts down gracefully on SIGTERM, finishing in-flight jobs before exiting, so systemd restarts will not drop work that is already running.
5. Install the River UI
Fetch the open-source River UI binary and install it under the same service account:
RIVER_ARCH=amd64
curl -L https://github.com/riverqueue/riverui/releases/latest/download/riverui_linux_${RIVER_ARCH}.gz \
| gzip -d > riverui
chmod +x riverui
sudo mv riverui /opt/river-worker/riveruiThe UI is publicly accessible by default, so always set basic auth. Add the credentials and database URL to a dedicated env file:
sudo tee /etc/river-ui.env >/dev/null <<EOF
DATABASE_URL=postgres://riverapp:CHANGE_ME_STRONG@127.0.0.1:5432/riverdb?sslmode=disable
RIVER_BASIC_AUTH_USER=admin
RIVER_BASIC_AUTH_PASS=CHANGE_ME_UI_PASSWORD
PORT=8080
EOF
sudo chmod 600 /etc/river-ui.env/etc/systemd/system/river-ui.service:
[Unit]
Description=River UI
After=postgresql.service
Wants=postgresql.service
[Service]
User=riversvc
Group=riversvc
EnvironmentFile=/etc/river-ui.env
ExecStart=/opt/river-worker/riverui
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now river-uiThe UI now listens on 127.0.0.1:8080 and is not reachable from the internet until you put a proxy in front of it.
6. Reverse proxy and TLS with Caddy
sudo apt -y install debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt -y install caddy/etc/caddy/Caddyfile:
jobs.example.com {
encode gzip
reverse_proxy 127.0.0.1:8080
}sudo systemctl reload caddyCaddy obtains and renews a Let's Encrypt certificate automatically. River UI's own basic auth plus TLS at the proxy gives you two layers. For sensitive deployments, add an IP allowlist in Caddy with a @blocked matcher or restrict 443 in the firewall to known office addresses.
7. Backups
Everything important lives in Postgres, so a logical dump of the database captures both your application data and the job tables. Create a backup script at /opt/river-worker/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F-%H%M)
DEST=/var/backups/river
mkdir -p "$DEST"
PGPASSWORD='CHANGE_ME_STRONG' pg_dump -U riverapp -h 127.0.0.1 riverdb \
| gzip > "$DEST/riverdb-$STAMP.sql.gz"
find "$DEST" -name 'riverdb-*.sql.gz' -mtime +14 -deletesudo chmod 700 /opt/river-worker/backup.shSchedule it with a systemd timer or cron, then push the dumps off the box to RamNode object storage or a remote target. A backup that stays only on the same VPS does not protect you against losing the VPS. For point-in-time recovery rather than nightly snapshots, enable WAL archiving in Postgres, but for most job-queue workloads a daily logical dump is sufficient.
8. Monitoring and alerting
River UI shows queue depth, throughput, and erroring jobs, which is the fastest way to spot a stuck queue. For automated alerts, query the river_job table directly. A growing count of jobs in available state or a spike in retryable jobs both indicate trouble:
SELECT state, count(*) FROM river_job GROUP BY state;Wire that into a small cron check or a Prometheus exporter.
One RamNode-specific point on alert delivery: RamNode restricts outbound mail and throttles or blocks direct SMTP on port 25 by default. Do not build alerting around a local sendmail or a direct port-25 connection, since those deliveries will silently fail. Send alerts through a transactional email provider over HTTPS (port 443), or through an authenticated relay on port 587 if you have one approved, rather than relying on the VPS to talk SMTP directly. The same applies to any email your worker jobs themselves send.
9. Upgrades
Updating River has two halves. Bump the library version in your Go module and rebuild the worker, then run any new migrations:
go get -u github.com/riverqueue/river@latest
go build -o /opt/river-worker/worker .
go install github.com/riverqueue/river/cmd/river@latest
river migrate-up --database-url "$DATABASE_URL"
sudo systemctl restart river-worker river-uiAlways run migrations before restarting workers on a version that expects the new schema. Refresh the River UI binary on the same cadence by repeating the download step in section 5.
10. Troubleshooting
If the worker exits immediately, check journalctl -u river-worker -n 50. A panic on start almost always means a worker was registered twice or the database is unreachable.
If the UI returns a blank page or an error on load, confirm the migrations ran against the same database the UI points to. The UI requires a working River schema to start.
If Caddy cannot get a certificate, verify the DNS A record resolves to the VPS and that ports 80 and 443 are open, since the ACME challenge needs both.
If jobs pile up in available and never run, the worker process is not running or is pointed at the wrong database. Confirm both services share the identical DATABASE_URL.
