Job Queue
    PostgreSQL

    Deploy River (Go Job Queue) on a VPS

    Self-host River, a Postgres-backed Go job queue, on a RamNode VPS — hardened PostgreSQL, schema migrations, worker binary, and the River UI behind Caddy TLS.

    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

    ComponentRolePort
    PostgreSQL 16Job storage plus application data5432 (localhost only)
    River CLIRuns schema migrationsn/a
    Worker binaryYour Go app that works jobsn/a
    River UIWeb interface for inspecting jobs and queues8080 (localhost only)
    CaddyReverse proxy and TLS termination80, 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.

    shell
    sudo adduser deploy
    sudo usermod -aG sudo deploy
    sudo apt update && sudo apt -y upgrade

    Lock down SSH in /etc/ssh/sshd_config:

    shell
    PermitRootLogin no
    PasswordAuthentication no

    Then 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.

    shell
    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 enable

    If you prefer CSF, the equivalent is allowing TCP_IN = 22,80,443 and leaving 5432 and 8080 closed. Enable unattended security upgrades:

    shell
    sudo apt -y install unattended-upgrades
    sudo dpkg-reconfigure --priority=low unattended-upgrades

    2. Install and harden PostgreSQL

    shell
    sudo apt -y install postgresql postgresql-contrib
    sudo systemctl enable --now postgresql

    Create the application database and a dedicated role:

    shell
    sudo -u postgres psql <<'SQL'
    CREATE ROLE riverapp WITH LOGIN PASSWORD 'CHANGE_ME_STRONG';
    CREATE DATABASE riverdb OWNER riverapp;
    SQL

    Confirm Postgres listens only on localhost. In /etc/postgresql/16/main/postgresql.conf:

    shell
    listen_addresses = 'localhost'

    In /etc/postgresql/16/main/pg_hba.conf, require password auth over local TCP and remove any trust lines:

    shell
    host    riverdb    riverapp    127.0.0.1/32    scram-sha-256

    Reload with sudo systemctl restart postgresql. Export a connection string you will reuse:

    shell
    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

    shell
    sudo apt -y install golang-go
    go install github.com/riverqueue/river/cmd/river@latest
    echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.bashrc && source ~/.bashrc

    Run the migrations that create River's tables and leader-election rows:

    shell
    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):

    shell
    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:

    shell
    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.

    shell
    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:

    shell
    [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.target
    shell
    sudo systemctl daemon-reload
    sudo systemctl enable --now river-worker

    River 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:

    shell
    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/riverui

    The UI is publicly accessible by default, so always set basic auth. Add the credentials and database URL to a dedicated env file:

    shell
    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:

    shell
    [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.target
    shell
    sudo systemctl daemon-reload
    sudo systemctl enable --now river-ui

    The 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

    shell
    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:

    shell
    jobs.example.com {
        encode gzip
        reverse_proxy 127.0.0.1:8080
    }
    shell
    sudo systemctl reload caddy

    Caddy 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:

    shell
    #!/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 -delete
    shell
    sudo chmod 700 /opt/river-worker/backup.sh

    Schedule 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:

    shell
    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:

    shell
    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-ui

    Always 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.