This is a guide on how to deploy the rfd-api and rfd-site repos into production.
If you want to setup your own RFD website just like Oxide has for your own company this guide is for you.
The current goal of this guide is to just learn how the rfd-api and rfd-site repos work and how to deploy them. This guide is not intended to supercede anything in rfd-api or rfd-site. As I document how things work I hope some useful changes will make it upstream to improve the original oxidecomputer repos:
For a fully containerized setup with Docker, see DOCKER.md.
This deploys rfd-api, rfd-processor, rfd-site, PostgreSQL, and Caddy all in Docker containers on a single server.
For automatic preview deployments of RFD content on pull requests, see PR-PREVIEWS.md.
This uses rfd-site's LOCAL_RFD_REPO mode to preview RFDs without requiring an API or database.
The following guide covers manual deployment on a VPS (building from source, using Vercel for the frontend).
- GitHub Account
- Digital Ocean Account
- Vercel Account
For a basic setup these are the current costs:
$15.15/month Hosted Postgresql DB
$7/month Droplet
$0/month Vercel (Free Plan?)
===============================
$22.15/month
By moving the DB to the droplet you could save on some costs.
- Create a Digital Ocean Project:
RFD Site - Create a Droplet: Ubuntu 24.04 (LTS) x64, Premium AMD NVMe SSD $7/mo
- Point DNS to the droplet (use 'rfd-api.yourdomain.com' for the subdomain)
- SSH into the droplet (
ssh [email protected])
If you are building rfd-api from source you will need to add swap space to your droplet.
# Create 2GB swap file
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# Make it permanent
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Add the required repositories:
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
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Update system
sudo apt update && sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold"
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
# Install Postgres client, Ruby, Node, Caddy, and other dependencies
sudo apt install -y \
postgresql-client \
ruby \
ruby-dev \
build-essential \
libpq-dev \
debian-keyring \
debian-archive-keyring \
apt-transport-https \
curl \
caddy \
nodejs
# Install Ruby gems
sudo gem install asciidoctor asciidoctor-pdf asciidoctor-mermaid rouge
# Install Node packages
sudo npm install -g @mermaid-js/mermaid-cli
cargo install diesel_cli --no-default-features --features postgres
sudo mkdir -p /opt/rfd-api
sudo chown $USER:$USER /opt/rfd-api
git clone https://github.com/oxidecomputer/rfd-api.git /opt/rfd-api
cd /opt/rfd-api
cargo build --release
Create a hosted postgres database at https://cloud.digitalocean.com/databases. Use PostgreSQL 14.
Before running migrations, create the config file with your database URL:
cd /opt/rfd-api/rfd-api
cp config.example.toml config.tomlEdit the config file and set the database_url:
vim config.tomldatabase_url = "postgres://user:pass@private-<do-db-server-name>.g.db.ondigitalocean.com:25060/rfd?sslmode=require"You'll configure the remaining settings later. For now, only the database_url is needed.
psql "$(grep '^database_url' /opt/rfd-api/rfd-api/config.toml | cut -d'"' -f2 | sed 's|/rfd?|/defaultdb?|')" -c "CREATE DATABASE rfd;"Run the rfd-installer to run the v-api migrations:
cd /opt/rfd-api
V_ONLY=1 DATABASE_URL="$(grep '^database_url' /opt/rfd-api/rfd-api/config.toml | cut -d'"' -f2)" cargo run -p rfd-installerRun the diesel migrations:
cd /opt/rfd-api/rfd-model
DATABASE_URL="$(grep '^database_url' /opt/rfd-api/rfd-api/config.toml | cut -d'"' -f2)" diesel migration runThere are 3 config files we need to setup:
rfd-api/config.toml(already created with database_url)rfd-api/mappers.tomlrfd-processor/config.toml
On your local:
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
Continue editing the config file you created earlier:
cd /opt/rfd-api/rfd-api
vim config.toml
Add these settings (database_url is already set):
log_format = "pretty"
# log_directory = "/var/log/rfd-api" # Comment out to use stdout
public_url = "https://rfd-api.yourdomain.com"
server_port = 8080
initial_mappers = "/opt/rfd-api/rfd-api/mappers.toml"Comment out or remove the entire [spec] section:
# [spec]
# title = ""
# description = ""
# contact_url = ""
# contact_email = ""
# output_path = ""Add this section to disable passwordless email login (not needed for GitHub OAuth):
[magic_link]
templates = []Find the [[keys]] section and remove the # Cloud KMS - Signer and # Cloud KMS - Verifier sections. For this guide we will be using a local key.
On your local:
cat private.pem | pbcopy
Add the private key to the config.toml file on the droplet:
[[keys]]
kind = "local_signer"
kid = "rfd-key-1"
private = """
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
"""
On your local:
cat private.pem | pbcopy
Add the public key to the config.toml file on the droplet:
[[keys]]
kind = "local_verifier"
kid = "rfd-key-1"
public = """
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
"""
Visit https://github.com/settings/developers and create a new OAuth App.
- Application name:
RFD SITE - Homepage URL:
https://rfd.yourdomain.com - Authorization callback URL:
https://rfd-api.yourdomain.com/login/oauth/github/code/callback - Enable Device flow:
false
Then add the client id and client secret to the config file:
[authn.oauth.github.web]
client_id = ""
client_secret = ""
redirect_uri = "https://<rfd-api-hostname>/login/oauth/github/code/callback"
Visit https://github.com/settings/developers and create a new OAuth App.
- Application name:
RFD CLI - Homepage URL:
https://rfd.yourdomain.com - Authorization callback URL:
https://rfd-api.yourdomain.com/login/oauth/github/code/callback(not used, but required) - Enable Device flow:
true
Then add the client id and client secret to the config file:
[authn.oauth.github.device]
client_id = ""
client_secret = ""
Create a private GitHub repo for the RFDs using the gh CLI:
cd ~/code
gh repo create rfd --private --clone
cd rfdOr via GitHub web UI: go to https://github.com/new, create a new private repository named rfd, then clone it locally.
Then add it to the config file:
# The GitHub repository to use to write RFDs
[services.github]
# GitHub user or organization
owner = ""
# GitHub repository name
repo = "rfd"
# Path within the repository where RFDs are stored
path = "rfd"
# Branch to use as the default branch of the repository
default_branch = "main"
A GitHub App is required for the webhook integration. While you can use a Personal Access Token for basic functionality, webhooks will fail because the rfd-api expects the installation field in webhook payloads, which only GitHub Apps provide.
Create the GitHub App:
-
Go to https://github.com/settings/apps/new (or for an org:
https://github.com/organizations/YOUR_ORG/settings/apps/new) -
Fill in the basic information:
- GitHub App name:
RFD API(must be unique across GitHub) - Homepage URL:
https://rfd-api.yourdomain.com
- GitHub App name:
-
Disable the Webhook for now (we'll configure it separately):
- Active: Unchecked (we'll enable after setup is complete)
-
Set the required permissions under Repository permissions:
- Contents: Read and write
- Metadata: Read-only (automatically selected)
- Pull requests: Read and write
-
Under Where can this GitHub App be installed?
- Select Only on this account
-
Click Create GitHub App
Generate a private key:
- After creating the app, scroll down to Private keys
- Click Generate a private key
- A
.pemfile will be downloaded - keep this safe!
Note the App ID:
At the top of the app settings page, note the App ID (a number like 123456).
Install the App on your RFD repository:
- In the left sidebar, click Install App
- Click Install next to your account
- Select Only select repositories and choose your RFD repo
- Click Install
- After installation, note the Installation ID from the URL:
https://github.com/settings/installations/INSTALLATION_ID
Add to config.toml:
Copy the contents of the downloaded .pem file and add to your config:
# GitHub App Installation
[services.github.auth]
app_id = 123456
installation_id = 789012
private_key = """
-----BEGIN RSA PRIVATE KEY-----
... your private key contents ...
-----END RSA PRIVATE KEY-----
"""Delete the token = "" line if present - you can only have one authentication method.
The initial_mappers path was already set in Basic settings:
initial_mappers = "/opt/rfd-api/rfd-api/mappers.toml"
Now let's copy the example mappers file:
cp mappers.example.toml mappers.toml
vim mappers.toml
"GetApiUserSelf"), NOT scope strings (e.g., "user:info:r"). Using scope strings will cause 403 errors because they deserialize incorrectly.
[[groups]]
name = "admin"
permissions = [
# User permissions
"GetApiUserSelf",
"GetApiUsersAssigned",
"GetApiUsersAll",
"CreateApiUser",
"ManageApiUsersAssigned",
"ManageApiUsersAll",
"CreateUserApiProviderLinkToken",
# API key permissions
"GetApiKeysAssigned",
"GetApiKeysAll",
"CreateApiKeySelf",
"CreateApiKeyAssigned",
"CreateApiKeyAll",
"ManageApiKeysAssigned",
"ManageApiKeysAll",
# Group permissions
"GetGroupsJoined",
"GetGroupsAll",
"CreateGroup",
"ManageGroupsAssigned",
"ManageGroupsAll",
"ManageGroupMembershipsAssigned",
"ManageGroupMembershipsAll",
# RFD permissions
"GetRfdsAssigned",
"GetRfdsAll",
"CreateRfd",
"UpdateRfdsAssigned",
"UpdateRfdsAll",
"ManageRfdsVisibilityAll",
"GetDiscussionsAssigned",
"GetDiscussionsAll",
"SearchRfds",
# OAuth client permissions
"GetOAuthClientsAssigned",
"GetOAuthClientsAll",
"CreateOAuthClient",
"ManageOAuthClientsAssigned",
"ManageOAuthClientsAll"
]
[[mappers]]
name = "Initial admin"
rule = "email_address"
email = "[email protected]"
groups = [
"admin"
]cd /opt/rfd-api/rfd-processor
cp config.example.toml config.toml
vim config.tomlUpdate the following settings (use your RFD repo from the previous step):
log_format = "json"
# Set to "write" when ready to persist changes, use "read" for testing
processor_update_mode = "read"
# Database connection (copy from rfd-api/config.toml)
database_url = "<same as rfd-api/config.toml>"
# Enable GitHub-related actions (for basic setup without GCP/Google Drive/Meilisearch)
actions = [
"CreatePullRequest",
"UpdatePullRequest",
"UpdateDiscussionUrl",
"EnsureRfdWithPullRequestIsInValidState",
"EnsureRfdOnDefaultIsInValidState",
]
# GitHub App authentication (same as rfd-api)
[auth.github]
app_id = 123456
installation_id = 789012
private_key = """
-----BEGIN RSA PRIVATE KEY-----
... your private key contents ...
-----END RSA PRIVATE KEY-----
"""
# GitHub repo settings - use YOUR repo from the previous step
[source]
owner = "<your-username-or-org>"
repo = "my-rfds"
path = "rfd"
default_branch = "main"Delete any token = "" line if present - use the same GitHub App authentication as rfd-api.
Comment out or remove the [[static_storage]], [pdf_storage], and [[search_storage]] sections if you're not using those features.
GitHub webhooks allow the rfd-api to immediately process RFD changes when you push to your repository, instead of waiting for the scanner's 15-minute interval.
Important: Webhooks require a GitHub App. The rfd-api webhook endpoint expects an installation field in the webhook payload, which is only included when webhooks are sent from a GitHub App installation. Regular repository webhooks (from Personal Access Tokens) will fail with a parsing error.
On your local machine, generate a random secret:
openssl rand -hex 32Save this secret - you'll need it for both the GitHub App and the rfd-api systemd service.
-
Go to https://github.com/settings/apps and click on your RFD API app
-
In the left sidebar, click General
-
Scroll down to the Webhook section
-
Configure the webhook:
- Active: Checked
- Webhook URL:
https://rfd-api.yourdomain.com/github - Webhook secret: The secret you generated above
-
Click Save changes
-
Under Subscribe to events, ensure Push is checked (this should be automatic based on the Contents permission)
-
Click Save changes
Edit the rfd-api service file to include the webhook secret:
sudo vim /etc/systemd/system/rfd-api.serviceUpdate the Environment line with your actual secret:
Environment="GITHUB_WEBHOOK_KEY=your-actual-secret-from-above"Then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart rfd-apiAfter pushing a change to your RFD repo, check GitHub's webhook delivery history:
- Go to Settings → Webhooks → click your webhook
- Click Recent Deliveries
- Successful deliveries show a green checkmark with status 202
You can also check the rfd-api logs:
sudo journalctl -u rfd-api -fConfigure Caddy:
sudo vim /etc/caddy/Caddyfile
Replace contents with:
rfd-api.yourdomain.com {
reverse_proxy localhost:8080
}
Verify the config and restart Caddy:
sudo caddy fmt --overwrite --config /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddyStart the server (it automatically looks for rfd-api/config.toml):
cd /opt/rfd-api
./target/release/rfd-apiStop it with Ctrl+C:
The first run loads the mappers/groups into the database. Verify they were created:
psql "$(grep '^database_url' /opt/rfd-api/rfd-api/config.toml | cut -d'"' -f2)" \
-c "SELECT name, permissions FROM access_groups;"You should see your admin group with the permissions you defined.
Now save the original mappers file and replace it with an empty one:
mv rfd-api/mappers.toml rfd-api/mappers.toml.loaded
cat > rfd-api/mappers.toml << 'EOF'
groups = []
mappers = []
EOFThis is to prevent conflicts on restart:
root@rfd4-api:/opt/rfd-api# ./target/release/rfd-api
2026-01-06T14:02:23.971291Z ERROR v_model::storage::postgres: error: Conflict
at /root/.cargo/git/checkouts/v-api-d40051c45e59353f/f69a1b8/v-model/src/storage/postgres.rs:1453
Restart rfd-api to verify it starts without errors:
./target/release/rfd-apiVerify it is working (run on the droplet in another ssh session):
curl http://localhost:8080/.well-known/openid-configurationYou should see JSON output. Once that works, verify HTTPS is working:
curl https://rfd-api.yourdomain.com/.well-known/openid-configurationCreate a systemd service file:
sudo vim /etc/systemd/system/rfd-api.serviceAdd the following:
[Unit]
Description=RFD API Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/rfd-api
ExecStart=/opt/rfd-api/target/release/rfd-api
Restart=on-failure
RestartSec=5
# Required for GitHub webhook signature verification (see "Setup GitHub Webhook" section)
Environment="GITHUB_WEBHOOK_KEY=your-webhook-secret-here"
[Install]
WantedBy=multi-user.targetNote: Replace your-webhook-secret-here with the actual webhook secret you'll create in the "Setup GitHub Webhook" section below.
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable rfd-api
sudo systemctl start rfd-apiCheck the status:
sudo systemctl status rfd-apiView logs:
sudo journalctl -u rfd-api -fFirst we need to create an OAuth client in the rfd-api so the site can authenticate users.
From your local machine, clone the rfd-api repo and install the CLI:
cd ~/code
git clone https://github.com/oxidecomputer/rfd-api.git
cd rfd-api
cargo install --path rfd-cliThis installs rfd-cli to ~/.cargo/bin which should already be in your PATH.
Configure the CLI to point to your rfd-api server:
rfd-cli config set host https://rfd-api.yourdomain.comYou should see: Configuration updated
Login via GitHub:
rfd-cli auth login githubAnd you should see:
To complete login visit: https://github.com/login/device and enter ASDF-1234
Configuration updated
Now create the OAuth client:
# 1. Create the OAuth client and capture the client ID
CLIENT_ID=$(rfd-cli sys oauth create | jq -r '.id')
echo "Client ID: $CLIENT_ID"
# 2. Add redirect URI
rfd-cli sys oauth redirect create --client-id "$CLIENT_ID" --redirect-uri "https://rfd.yourdomain.com/auth/github/callback"
# 3. Create client secret and capture it
CLIENT_SECRET=$(rfd-cli sys oauth secret create --client-id "$CLIENT_ID" | jq -r '.key')
echo "Client Secret: $CLIENT_SECRET"Save the client ID and secret - you'll need them for Vercel.
Fork the oxidecomputer/rfd-site repo to your GitHub account:
cd ~/code
gh repo fork oxidecomputer/rfd-site --clone --fork-name rfd4-site
cd my-rfd-siteOr via GitHub web UI: visit https://github.com/oxidecomputer/rfd-site and click "Fork", then customize the repository name.
Install the Vercel CLI and deploy:
npm i -g vercel
vercelWhen prompted:
- Set up and deploy? → Yes
- Which scope? → Select your account/team
- Link to existing project? → No (for new project)
- Project name? → Enter your project name
- Detected a repository. Connect it? → No (avoids git author issues)
- Modify settings? → No
In the Vercel dashboard (or via CLI), add these environment variables to the production environment:
| Variable | Value |
|---|---|
RFD_API |
https://rfd-api.yourdomain.com |
RFD_API_CLIENT_ID |
<client id from step 1> |
RFD_API_CLIENT_SECRET |
<secret key from step 3> |
RFD_API_GITHUB_CALLBACK_URL |
https://rfd.yourdomain.com/auth/github/callback |
SESSION_SECRET |
<random string, e.g. openssl rand -hex 32> |
Or via CLI:
# Generate a session secret first
SESSION_SECRET=$(openssl rand -hex 32)
echo "SESSION_SECRET: $SESSION_SECRET"
# Add each environment variable (you'll be prompted for values)
vercel env add SESSION_SECRET # paste the generated secret above
vercel env add RFD_API # https://rfd-api.yourdomain.com
vercel env add RFD_API_CLIENT_ID # client id from OAuth client creation
vercel env add RFD_API_CLIENT_SECRET # secret from OAuth client creation
vercel env add RFD_API_GITHUB_CALLBACK_URL # https://rfd.yourdomain.com/auth/github/callbackAdd a custom domain via CLI:
vercel domains add rfd.yourdomain.comThen add an A record in your DNS provider (e.g., DigitalOcean):
| Type | Hostname | Value |
|---|---|---|
| A | rfd | 76.76.21.21 |
Note: 76.76.21.21 is Vercel's standard IP for all custom domains.
Vercel will verify the domain and send you an email when it's ready.
After adding environment variables, trigger a redeploy:
vercel --prodYour RFD site should now be live at https://rfd.yourdomain.com!
Now that everything is deployed, add your first RFD to the repository you created earlier.
From your local machine, navigate to the RFD repo you created earlier and add your first RFD:
cd ~/code/rfd
mkdir -p rfd/0001
cat > rfd/0001/README.adoc << 'EOF'
= RFD 1 My First RFD
Your Name <[email protected]>
:state: published
== Introduction
This is my first RFD.
== Background
Add background information here.
== Proposal
Describe your proposal here.
EOFAsciiDoc format notes:
- Title line:
= RFD 1 Title Here - Author line immediately after title (not as
:authors:attribute) :state:attribute after author line- Blank line before content sections
Push to GitHub:
git add .
git commit -m "Add first RFD"
git pushOn the droplet, run the processor to sync RFDs from GitHub to the database:
ssh [email protected]
cd /opt/rfd-api
./target/release/rfd-processorThe processor will scan your GitHub repo and import the RFDs. Once complete, refresh your RFD site to see your first RFD!
Note: By default, new RFDs are only visible to admins. To make an RFD public, use the CLI:
rfd-cli edit visibility --number 1 --visibility publicThe rfd-processor is designed to run as a long-running service with built-in intervals:
scanner_interval- how often to scan GitHub for RFD changes (default: 900 seconds / 15 minutes)processor_interval- how often to process job batches (default: 30 seconds)
sudo vim /etc/systemd/system/rfd-processor.serviceAdd the following:
[Unit]
Description=RFD Processor
After=network.target rfd-api.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/rfd-api
ExecStart=/opt/rfd-api/target/release/rfd-processor
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable rfd-processor
sudo systemctl start rfd-processor# View service status
sudo systemctl status rfd-processor
# View processor logs
sudo journalctl -u rfd-processor -fTo change how often the processor scans GitHub, edit the processor config:
vim /opt/rfd-api/rfd-processor/config.toml# How often the processor scanner should check the remote GitHub repo for RFDs (in seconds)
# Default: 900 (15 minutes). Adjust based on your repo size and GitHub rate limits.
scanner_interval = 900
# How often to select a batch of jobs to process (in seconds)
processor_interval = 30Then restart the service:
sudo systemctl restart rfd-processor