Secure VPS Deployment
This tutorial provisions ProPR on a brand-new Linux VPS and hardens both the host and the deployment. It assumes a fresh Ubuntu 22.04/24.04 (or Debian 12) image with only root SSH access — the kind of box most cloud providers hand you.
The end state: ProPR runs from the prebuilt images, the application is reachable only over HTTPS through a reverse proxy, every service port is bound to localhost, the host firewall denies everything except SSH and web traffic, and access is limited to the GitHub users you whitelist.
For the ProPR-specific configuration touched here (.env, GitHub auth,
event intake), the Server Setup, GitHub Authentication,
and Deployment pages go deeper; this page focuses
on the host and network hardening around them.
Replace propr.example.com with your domain, you with your admin username, and
203.0.113.10 with your server IP throughout. Steps 1–4 are run as root (or
with sudo). From step 5 onward you must be logged in as the unprivileged you
user — the guide will remind you where to switch.
1. Create An Admin User And Lock Down SSH
Run as: root.
Working as root over password SSH is the most common way these boxes get compromised. Create an unprivileged sudo user and switch to key-only login.
# As root on the VPS:
adduser you
usermod -aG sudo you
# Copy your SSH public key to the new user (from your laptop, or paste manually):
mkdir -p /home/you/.ssh
cp ~/.ssh/authorized_keys /home/you/.ssh/authorized_keys # if root already has your key
chown -R you:you /home/you/.ssh
chmod 700 /home/you/.ssh && chmod 600 /home/you/.ssh/authorized_keys
Confirm you can open a new SSH session as you and run sudo -v before
continuing — do not close your root session until that works.
Then disable password and root login in /etc/ssh/sshd_config (or a drop-in
under /etc/ssh/sshd_config.d/):
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
Apply it. The SSH service is named ssh on most Debian/Ubuntu images but sshd
on some, so try both:
sudo systemctl restart ssh || sudo systemctl restart sshd
Keep your existing root session open and confirm a new session still connects before relying on the change.
2. Patch The System And Enable Automatic Security Updates
Run as: root (or you with sudo).
sudo apt update && sudo apt -y upgrade
sudo apt -y install unattended-upgrades fail2ban
sudo dpkg-reconfigure -plow unattended-upgrades # enable the stable security channel
fail2ban ships with an SSH jail enabled by default, which throttles brute-force
attempts on port 22. Confirm it is running:
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
3. Configure The Host Firewall
Run as: root (or you with sudo).
This is where the tutorial branches. Choose how the outside world reaches ProPR:
- Public TLS (default): open ports 80/443 below, then terminate TLS with nginx and Certbot in step 9.
- Cloudflare Tunnel:
cloudflaredmakes an outbound connection to Cloudflare's edge, so the VPS needs no public inbound web ports. Skip the twoufw allow 80/443lines below, complete steps 1–8 here, then follow Advanced VPS Hardening instead of step 9 — it covers the localhost-only nginx config and the tunnel together, and can add an SSO identity gate. Resume here at step 10 afterward.
Either way you still need the firewall (allow SSH) and nginx (it routes requests locally).
Allow SSH (and, for the public-TLS path, web traffic); deny everything else inbound.
sudo apt -y install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
# Public-TLS path only (step 9 with nginx + Certbot). SKIP these two lines on
# the Cloudflare Tunnel path — it needs no public web ports.
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Docker inserts its own iptables rules in the DOCKER chain when a container
publishes a port to all interfaces (the default -p 4000:4000 form). Those
rules are evaluated before UFW's INPUT rules, so a published port is reachable
from the internet even though ufw status shows it denied.
This tutorial avoids the problem entirely by binding ProPR's service ports to
127.0.0.1 (next step) so Docker never opens them on the public interface. Do
not rely on UFW alone to hide a container port — bind it to localhost, or do not
publish it.
4. Install Docker And The ProPR CLI
Run as: root (or you with sudo). This is the last root section — steps 5
onward run as you.
Install Docker Engine from Docker's official apt repository. As with NodeSource
below, add the signing key and a signed-by apt source rather than piping
get.docker.com straight into a root shell — apt then verifies every Docker
package against the pinned GPG key on each apt update, keeping the posture
consistent with the rest of this guide:
# Add Docker's official GPG key and signed-by apt source. Derive the distro
# ("ubuntu" or "debian") from /etc/os-release so the same block works on either —
# do not hard-code one or the other.
sudo apt -y install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
DISTRO=$(. /etc/os-release && echo "$ID") # "ubuntu" or "debian"
curl -fsSL "https://download.docker.com/linux/$DISTRO/gpg" \
| sudo gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$DISTRO $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker you # run docker without sudo; log out/in to apply
sudo systemctl enable --now docker
The DISTRO variable resolves to ubuntu or debian from /etc/os-release, so
the key URL and apt source point at the right repository on either distribution
without manual editing. The convenience curl -fsSL https://get.docker.com | sudo sh script does the same repository setup but executes a remote script as root;
the explicit steps above keep the security posture consistent with the rest of
this guide.
Adding you to the docker group is equivalent to granting root on the host
(the Docker socket can mount any path). Keep that group membership limited to
your admin user.
Install Node.js 22+ (required by the ProPR CLI) and the CLI itself. On a
hardening-focused box, add NodeSource as a signed apt repository (keyring +
signed-by) rather than piping their setup script straight into a root shell —
apt then verifies every package against the pinned GPG key on each apt update:
# Add NodeSource's signing key and a signed-by apt source for Node.js 22.x
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| sudo gpg --dearmor --yes -o /usr/share/keyrings/nodesource.gpg
echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
| sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update && sudo apt -y install nodejs
sudo npm install -g propr-cli
The one-line curl … | sudo -E bash - form from the
official NodeSource instructions
does the same repository setup but executes a remote script as root; the explicit
steps above keep the security posture consistent with the rest of this guide.
Confirm the CLI is on the expected PATH before continuing:
which propr && propr --version # should resolve to the global npm prefix, e.g. /usr/bin/propr
5. Authenticate Agent CLIs On The Host
Run as: you (the unprivileged user) for this and every following section.
All remaining steps must run as the unprivileged you user. If you are still in
a root session, switch now:
su - you
# or open a new SSH session: ssh [email protected]
Running propr init stack or agent logins as root places credentials and
files under /root, where the stack cannot find them.
If you want to reuse credentials already on this host, install the CLI for
each such agent first (only propr-cli, Docker, and Node were installed above
— the agent CLIs are separate), then log in as you (not root) so the
credential directory lives under /home/you. You can skip host CLI installation
for Claude, Codex, Antigravity, and OpenCode and choose Log in to a new
account after adding the agent in the Web UI instead. For an existing account:
claude auth login # Claude Code -> ~/.claude (npm i -g @anthropic-ai/claude-code)
# or: agy login # Antigravity -> ~/.gemini (curl -fsSL https://antigravity.google/cli/install.sh | bash)
These two are examples only. Install and log in to whichever agents
you plan to run; each writes its login state to a credential directory under
/home/you that the stack mounts into worker runs.
curl … | bash on a hardened hostOn a security-hardened box, follow each vendor's official installation
documentation as the primary path — verify checksums or use a signed package
repository where one is offered. The curl -fsSL … | bash one-liners in the
table below are convenience snippets only: they pipe a remote script straight
into a shell, can change upstream without notice, and bypass the package-pinning
posture used for Node.js and cloudflared elsewhere in this guide. Treat
Agents and Models as the canonical source for
each agent's current, vendor-recommended install command and verify there if a
command fails:
| Agent | Install | Credential directory |
|---|---|---|
| Claude Code | npm i -g @anthropic-ai/claude-code | ~/.claude |
| Antigravity | curl -fsSL https://antigravity.google/cli/install.sh | bash | ~/.gemini |
| Codex | see Agents and Models | ~/.codex |
| OpenCode | curl -fsSL https://opencode.ai/install | bash | ~/.config/opencode |
| Vibe | see Agents and Models | ~/.vibe |
See Agents and Models for each agent's exact
install command, login step, and credential directory — including the OpenCode
XDG_DATA_HOME requirement for file-based auth, and the full Codex and Vibe
install/login steps not shown inline above.
propr check in step 11 validates configured host credential mounts. Finish the
linked agent-specific setup now for every existing account you intend to reuse.
Accounts that you will create through direct Web login can be added after the
stack starts and do not need a provider CLI or HOST_* path on the host.
6. Scaffold The Stack
Run as: you.
Create a runtime directory and scaffold it with the CLI. /srv/propr is a
conventional location; keep it owned by you.
sudo mkdir -p /srv/propr && sudo chown -R you:you /srv/propr
cd /srv/propr
propr init stack
propr init stack creates data/, logs/, and repos/, writes .env from the
bundled template, and records any agent credential directories it detects under
/home/you.
7. Configure GitHub Access
Run as: you.
Choose one auth mode: by default the shared, hosted ProPR App via the token relay (no private key on the server), with your own GitHub App as the advanced option. Full detail, including mode precedence, lives in GitHub Authentication.
-
Own GitHub App — copy the App private key onto the server first, then lock down its permissions (readable only by
you) and set the App identifiers in.env. From your laptop, upload the.pemyou downloaded from GitHub:# On your laptop — copy the key to the runtime directory on the VPS:scp ./app-private-key.pem [email protected]:/srv/propr/app-private-key.pem# On the VPS — restrict the key now that the file exists:chmod 600 /srv/propr/app-private-key.pem# in /srv/propr/.envGH_AUTH_MODE=app # inferred from the keys below; set it explicitly to avoid surprisesGH_APP_ID=123456GH_INSTALLATION_ID=987654HOST_GH_PRIVATE_KEY=/srv/propr/app-private-key.pem -
Shared App via relay (default) — pick ProPR Connect (default ProPR GitHub App) in
propr setup, which enrolls and writes the relay/routing credentials to.envfor you. To enroll standalone:cd /srv/propr # run from the stack directory so the token lands in its .envpropr login # store a GitHub token (skips if you already have one)propr relay enroll # discovers the installation, then writes a relay token to .env
8. Bind Service Ports To Localhost
Run as: you (editing /srv/propr/.env).
This is the step that makes the firewall meaningful. Keep the API and UI ports bound to the loopback interface only (the launcher default; setting them explicitly guards against later edits), and set the public URLs explicitly.
In /srv/propr/.env:
# Bind published ports to localhost so they are never exposed on the public NIC.
API_PORT=127.0.0.1:4000
UI_PORT=127.0.0.1:5173
# Explicit public URLs (the reverse proxy serves these over HTTPS).
FRONTEND_URL=https://propr.example.com
API_PUBLIC_URL=https://propr.example.com
GH_OAUTH_CALLBACK_URL=https://propr.example.com/api/auth/github/callback
# nginx reaches the container through the launcher's private Docker bridge.
# uniquelocal is safe here only because API_PORT is loopback-bound above; the
# launcher rejects this proxy setting if the API could accept other private peers.
PROPR_TRUSTED_PROXY_PEERS=uniquelocal
# Browser sign-in (GitHub OAuth App + signed session cookies). Without these the
# dashboard cannot complete a GitHub login after the stack starts in step 11.
GH_OAUTH_CLIENT_ID=your_github_oauth_client_id
GH_OAUTH_CLIENT_SECRET=your_github_oauth_client_secret
SESSION_SECRET=paste_the_generated_hex_string_here
host:port formAPI_PORT/UI_PORT are Docker port bindings, so they take a bare
127.0.0.1:4000 host-and-port value. Do not prefix them with
http:// or add a path: a value like http://127.0.0.1:4000 is invalid here and
will break the published-port binding. The http(s)://… form belongs only on the
FRONTEND_URL/API_PUBLIC_URL/GH_OAUTH_CALLBACK_URL lines below.
.env does not run shell commands.env files are read literally; they do not evaluate $(...). If you paste
SESSION_SECRET=$(openssl rand -hex 32) the app uses that literal text as the
secret. Run the command in your shell first, then paste the resulting hex string:
openssl rand -hex 32 # copy the output into SESSION_SECRET above
Now that .env holds OAuth and session secrets, lock it down so only you can
read it:
chmod 600 /srv/propr/.env
Leave REDIS_EXTERNAL_PORT unset — Redis then stays on the internal Docker
network and is never published to the host at all.
GH_OAUTH_CLIENT_ID/GH_OAUTH_CLIENT_SECRET come from a GitHub OAuth App
(used for the browser sign-in flow) — these are separate from the GitHub App
identifiers you set in step 7 (used to act on repositories). Register the OAuth
App with its Authorization callback URL set to the
GH_OAUTH_CALLBACK_URL above. See
GitHub Authentication for the full setup.
The launcher derives API_PUBLIC_URL/FRONTEND_URL/GH_OAUTH_CALLBACK_URL from
the port values when you don't set them (http://localhost:<port>). Behind a
reverse proxy those localhost URLs break sign-in redirects and links, so set the
three public URLs above yourself.
9. Terminate TLS With A Reverse Proxy
Run as: you with sudo (system service config); the .env stays owned by
you.
On the Cloudflare Tunnel path (decision point in step 3), skip this step and follow Advanced VPS Hardening instead, then resume at step 10.
Point your domain's DNS A record at the server (propr.example.com → 203.0.113.10) before running Certbot, and wait for it to resolve
(dig +short propr.example.com should return 203.0.113.10). Certbot's HTTP-01
challenge fails if the name does not yet resolve to this host.
Install nginx and Certbot, then obtain a certificate.
sudo apt -y install nginx
Create /etc/nginx/sites-available/propr.conf:
server {
listen 80;
server_name propr.example.com;
# Web UI
location / {
proxy_pass http://127.0.0.1:5173;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API, OAuth callback, and Socket.IO live on the API service
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Live task updates ride a long-lived websocket; nginx's default 60s
# read/send timeouts would drop idle connections and stall UI updates.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# GitHub webhook endpoint (only needed if you enable webhooks; see below).
# ProPR serves a single POST /webhook route, so match it exactly with `= `:
# a bare `location /webhook` is a prefix match that would also proxy siblings
# like /webhookadmin or /webhook-test to the API.
location = /webhook {
proxy_pass http://127.0.0.1:4000/webhook;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable the site and provision HTTPS:
sudo ln -s /etc/nginx/sites-available/propr.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d propr.example.com
Certbot rewrites the server block to listen on 443 with the certificate and
adds an HTTP→HTTPS redirect; its systemd timer renews automatically. Confirm the
renewal timer is active:
sudo systemctl status certbot.timer
10. Restrict Who Can Trigger ProPR
Run as: you (editing /srv/propr/.env).
ProPR acts on GitHub as a bot identity; anyone who can comment a command on a
watched repository could otherwise drive it. Whitelist the GitHub usernames
allowed to use the system in /srv/propr/.env:
GITHUB_USER_WHITELIST=you,teammate-a,teammate-b
By default ProPR receives GitHub events over the hosted-App WebSocket routing
(GITHUB_EVENT_INTAKE_MODE=routing_websocket), an outbound connection that
needs no inbound endpoint and no webhook secret — the natural fit behind this
hardened, localhost-bound setup. You can leave intake as-is.
The own-App direct_webhook mode is an advanced opt-in that requires a public
POST /webhook endpoint and a webhook secret (the API refuses to start in that
mode without one). It is not recommended behind a Cloudflare Access SSO gate:
if you arrived here from Advanced VPS Hardening,
GitHub's webhook POSTs cannot pass Access and are silently dropped unless you add
the explicit /webhook Bypass policy described there — so prefer the default
routing WebSocket. If you do run your own App webhook, generate the secret:
GITHUB_EVENT_INTAKE_MODE=direct_webhook
GH_WEBHOOK_SECRET=paste_the_generated_hex_string_here
As with SESSION_SECRET, .env does not evaluate $(...). Generate the value in
your shell first, then paste the hex string above (and re-run chmod 600 /srv/propr/.env if you edit the file as a different user):
openssl rand -hex 32 # copy the output into GH_WEBHOOK_SECRET above
Set the same secret and the https://propr.example.com/webhook URL in your
GitHub App's webhook settings. See Server Setup
for the full intake walkthrough.
11. Verify And Start
Run as: you.
cd /srv/propr
propr check # validates Docker, images, agent credentials, and GitHub auth mode
propr start --no-tui # pull images and start the stack without the interactive dashboard
The service containers run with --restart unless-stopped, so they come back
after a reboot as long as Docker starts on boot (systemctl enable docker, done
in step 4). Check status any time:
propr status # local stack containers
propr remote-status # backend health: daemon, workers, Redis, GitHub auth
Open https://propr.example.com, sign in with GitHub, and confirm the dashboard
loads. GITHUB_USER_WHITELIST (step 10) gates both who can trigger work from
GitHub and who can sign in to the Web UI and API; an empty whitelist allows every
GitHub user. To keep unauthenticated traffic from reaching the application at
all, put it
behind an SSO gate such as the Cloudflare Access layer in
Advanced VPS Hardening. From a machine off the server,
verify the raw ports are not reachable — these should both fail/time out:
curl -m 5 http://203.0.113.10:4000/ # API port — must NOT respond
curl -m 5 http://203.0.113.10:5173/ # UI port — must NOT respond
You can also confirm the bind locally on the VPS — the published ports should
show 127.0.0.1:4000/127.0.0.1:5173 (or [::1]:...); a 0.0.0.0:* or
*:* value would mean they are listening on every interface:
ss -ltnp | grep -E ':4000|:5173' # both must show a 127.0.0.1 bind; 0.0.0.0 means every interface
Only https://propr.example.com (443) and SSH (22) should answer.
12. Operate It
Run as: you; the sudo in the update command matches the root-owned global
CLI install from step 4.
-
Updates: run
propr start --restartfrom the stack directory so it acts on the right runtime root:sudo npm update -g propr-clicd /srv/propr && propr start --restart # pulls matching images, recreates the stackpropr start --restartresolves its stack relative to the current directory (or--root), socd /srv/proprfirst to avoid restarting against the wrong path. Use the same method you installed the CLI with —sudohere matches the root-owned global install in step 4. If you instead installed under a user-managed npm prefix (sonpm -gneeds nosudo), drop thesudo; mixing the two can update a different copy or create root-owned files in a user-owned prefix. Unattended-upgrades keeps the OS patched. -
Backups: persist
/srv/propr/data(SQLite, WAL-aware) and thepropr-redis-datavolume;repos/is re-creatable. See Maintenance. -
Secrets hygiene: keep
.envand the App private key readable only byyou(chmod 600), and never commit them. ProPR redacts relay andghs_tokens from logs. -
Docker socket: the stack mounts
/var/run/docker.sockto launch agent containers as siblings. Treat anyone with access to that socket, or to thedockergroup, as having root on the host.
For deeper operational guidance — image manifest, container names, metrics, and recovery — see Deployment and Maintenance. To remove all public inbound web traffic with a Cloudflare Tunnel and an SSO gate, take the tunnel branch at the step 3 decision point: Advanced VPS Hardening.