Skip to content

VPS Deployment Guide

This guide sets up automated deployment for a .NET API named Obaki running on an Ubuntu VPS.

Target setup:

GitHub Actions
-> build/test .NET API
-> dotnet publish
-> upload published files to VPS
-> switch /opt/obaki/current symlink
-> restart obaki systemd service
-> Caddy reverse proxies traffic to the API

Recommended stack:

Ubuntu VPS
Caddy
systemd
.NET Runtime
GitHub Actions
SSH
rsync

This is lighter than Docker and fits better on a small VPS such as:

1 vCPU
1 GB RAM
25 GB disk

SSH into your VPS.

Terminal window
ssh your-user@your-vps-ip

Install the .NET runtime required by your API.

For .NET 10, follow the official Microsoft install command for your Ubuntu version.

Example structure:

Terminal window
sudo apt update
sudo apt install -y dotnet-runtime-10.0

Verify:

Terminal window
dotnet --info

If your app is an ASP.NET Core API, install the ASP.NET Core runtime:

Terminal window
sudo apt install -y aspnetcore-runtime-10.0

Create a dedicated Linux user for the app.

Terminal window
sudo adduser --system --group --home /opt/obaki obaki

Create the app folders:

Terminal window
sudo mkdir -p /opt/obaki/releases
sudo mkdir -p /opt/obaki/shared/logs
sudo chown -R obaki:obaki /opt/obaki

Expected structure:

/opt/obaki/
releases/
shared/
logs/

Later, the deployment will create:

/opt/obaki/current -> /opt/obaki/releases/<release-folder>

Production secrets should live on the VPS, not in GitHub.

Create:

Terminal window
sudo mkdir -p /etc/obaki
sudo nano /etc/obaki/obaki.env

Example:

ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://127.0.0.1:5000
ConnectionStrings__DefaultConnection=Host=localhost;Port=5432;Database=obaki;Username=obaki;Password=change-this
Resend__ApiKey=re_xxxxxxxxxxxxxxxxx
ExternalProvider__BaseUrl=https://example.com/api
ExternalProvider__ApiKey=change-this

Use __ for nested .NET configuration.

Example:

Resend__ApiKey=re_xxx

maps to:

builder.Configuration["Resend:ApiKey"]

Example:

ConnectionStrings__DefaultConnection=Host=localhost;Database=obaki;Username=obaki;Password=secret

maps to:

builder.Configuration.GetConnectionString("Default")

Lock down the file:

Terminal window
sudo chown root:obaki /etc/obaki/obaki.env
sudo chmod 640 /etc/obaki/obaki.env

Do not commit .env.

Your repository should only have:

.env.example
appsettings.json
appsettings.Development.json

Create the service file:

Terminal window
sudo nano /etc/systemd/system/obaki.service

Paste:

[Unit]
Description=Obaki .NET API
After=network.target
[Service]
WorkingDirectory=/opt/obaki/current
ExecStart=/usr/bin/dotnet /opt/obaki/current/Obaki.Api.dll
Restart=always
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=obaki
User=obaki
EnvironmentFile=/etc/obaki/obaki.env
Environment=DOTNET_GCHeapHardLimitPercent=40
[Install]
WantedBy=multi-user.target

Important:

/opt/obaki/current/Obaki.Api.dll

must match your actual published DLL name.

Examples:

Obaki.Api.dll
Obaki.Server.dll
Obaki.WebApi.dll

Reload systemd:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable obaki

The app may not start yet because /opt/obaki/current does not exist until the first deployment.


Create a subdomain for the Obaki API.

Example:

api.jjosh.dev

In Cloudflare:

Cloudflare
-> Your domain
-> DNS
-> Records
-> Add record

Add this DNS record:

Type: A
Name: api
IPv4 address: your VPS public IP
Proxy status: DNS only
TTL: Auto

Example:

A api 123.123.123.123

This creates:

api.jjosh.dev

Start with:

DNS only

This makes debugging easier because Caddy can directly request and manage the TLS certificate.

After everything works, you can switch the record to:

Proxied

Recommended Cloudflare SSL setting:

SSL/TLS
-> Overview
-> Full (strict)

Caddy automatically gets a valid Let’s Encrypt certificate when the DNS record points to your VPS correctly.

If Full (strict) fails during first setup, temporarily use:

Full

Then switch back to:

Full (strict)

Final DNS flow:

api.jjosh.dev
-> Cloudflare DNS
-> VPS public IP
-> Caddy
-> 127.0.0.1:5000
-> Obaki .NET API

Do not expose the .NET API directly to the internet.

Keep Obaki bound to:

127.0.0.1:5000

Only Caddy should listen publicly on ports:

80
443

Edit your Caddyfile:

Terminal window
sudo nano /etc/caddy/Caddyfile

Example:

api.jjosh.dev {
reverse_proxy 127.0.0.1:5000
}

If Obaki shares the same VPS with ntfy:

ntfy.yourdomain.com {
reverse_proxy 127.0.0.1:8080
}
api.yourdomain.com {
reverse_proxy 127.0.0.1:5000
}

Reload Caddy:

Terminal window
sudo systemctl reload caddy

Check status:

Terminal window
sudo systemctl status caddy

Use a deploy user for GitHub Actions.

Terminal window
sudo adduser deploy

Add the deploy user to the obaki group:

Terminal window
sudo usermod -aG obaki deploy

Allow the deploy user to write to /opt/obaki:

Terminal window
sudo chown -R obaki:obaki /opt/obaki
sudo chmod -R 775 /opt/obaki

8. Allow deploy user to restart only Obaki

Section titled “8. Allow deploy user to restart only Obaki”

Open sudoers safely:

Terminal window
sudo visudo

Add this line:

deploy ALL=NOPASSWD: /bin/systemctl restart obaki, /bin/systemctl is-active obaki, /bin/systemctl status obaki

This allows GitHub Actions to restart only the obaki service.


On your local machine, create a deploy SSH key:

Terminal window
ssh-keygen -t ed25519 -C "github-actions-obaki" -f obaki_deploy_key

This creates:

obaki_deploy_key
obaki_deploy_key.pub

Copy the public key to the VPS deploy user:

Terminal window
ssh-copy-id -i obaki_deploy_key.pub deploy@your-vps-ip

Test:

Terminal window
ssh -i obaki_deploy_key deploy@your-vps-ip

The private key content goes into GitHub Secrets.

Show the private key:

Terminal window
cat obaki_deploy_key

Copy the full output, including:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

In GitHub:

Repository
-> Settings
-> Secrets and variables
-> Actions
-> New repository secret

Add:

VPS_HOST=your-vps-ip-or-hostname
VPS_USER=deploy
VPS_PORT=22
VPS_SSH_KEY=<private key content>
VPS_SSH_KNOWN_HOSTS=<known_hosts entry for your VPS, optional but recommended>

VPS_SSH_KNOWN_HOSTS is optional but recommended. If it is not set, the workflow falls back to ssh-keyscan during deployment and validates that a host key was collected.

To pin the host key, create VPS_SSH_KNOWN_HOSTS from a trusted host key line.

The host in this secret must match VPS_HOST, and the port must match VPS_PORT.

For port 22:

Terminal window
ssh-keyscan -p 22 your-vps-ip-or-hostname

For a custom SSH port, use the same port as VPS_PORT. The output should start with [host]:port.

Terminal window
ssh-keyscan -p 2222 your-vps-ip-or-hostname

Verify the fingerprint with your VPS provider or server console before saving it as a GitHub secret.

Do not put production app secrets here unless GitHub Actions truly needs them.

Production app secrets should stay in:

/etc/obaki/obaki.env

11. Add GitHub Actions deployment workflow

Section titled “11. Add GitHub Actions deployment workflow”

Create this file in your repository:

.github/workflows/deploy.yml

Paste:

name: Deploy Obaki API
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
concurrency:
group: obaki-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build
- name: Publish
run: |
dotnet publish ./src/Obaki.Api/Obaki.Api.csproj \
--configuration Release \
--output ./publish
- name: Configure SSH
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_PORT: ${{ secrets.VPS_PORT }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KNOWN_HOSTS: ${{ secrets.VPS_SSH_KNOWN_HOSTS }}
run: |
set -eo pipefail
require_secret() {
if [ -z "$2" ]; then
echo "::error::$1 is required for deployment."
exit 1
fi
}
require_secret "VPS_HOST" "$VPS_HOST"
require_secret "VPS_PORT" "$VPS_PORT"
require_secret "VPS_USER" "$VPS_USER"
require_secret "VPS_SSH_KEY" "$VPS_SSH_KEY"
set -u
HOST="$VPS_HOST"
PORT="$VPS_PORT"
LOOKUP_HOST="$HOST"
SSH_KEY_FILE="$HOME/.ssh/deploy_key"
KNOWN_HOSTS_FILE="$HOME/.ssh/known_hosts"
if [ "$PORT" != "22" ]; then
LOOKUP_HOST="[$HOST]:$PORT"
fi
mkdir -p "$HOME/.ssh"
printf '%s\n' "$VPS_SSH_KEY" | tr -d '\r' > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
if [ -n "$VPS_SSH_KNOWN_HOSTS" ]; then
printf '%s\n' "$VPS_SSH_KNOWN_HOSTS" | tr -d '\r' > "$KNOWN_HOSTS_FILE"
else
echo "::warning::VPS_SSH_KNOWN_HOSTS is not set. Falling back to ssh-keyscan for $HOST:$PORT."
if ! ssh-keyscan -p "$PORT" "$HOST" > "$KNOWN_HOSTS_FILE"; then
echo "::error::ssh-keyscan could not collect a host key for $HOST:$PORT. Check VPS_HOST, VPS_PORT, and VPS firewall rules."
exit 1
fi
fi
chmod 600 "$KNOWN_HOSTS_FILE"
if ! ssh-keygen -F "$LOOKUP_HOST" -f "$KNOWN_HOSTS_FILE" >/dev/null; then
echo "::error::VPS_SSH_KNOWN_HOSTS does not contain a host key for $LOOKUP_HOST. Regenerate it with: ssh-keyscan -p $PORT $HOST"
exit 1
fi
ssh -i "$SSH_KEY_FILE" -p "$PORT" \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$KNOWN_HOSTS_FILE" \
-o IdentitiesOnly=yes \
"$VPS_USER@$HOST" \
"true"
- name: Deploy to VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_PORT: ${{ secrets.VPS_PORT }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
set -euo pipefail
RELEASE=$(date +%Y%m%d%H%M%S)-${GITHUB_SHA::7}
REMOTE_RELEASE="/opt/obaki/releases/$RELEASE"
SSH_KEY_FILE="$HOME/.ssh/deploy_key"
KNOWN_HOSTS_FILE="$HOME/.ssh/known_hosts"
LOOKUP_HOST="$VPS_HOST"
if [ "$VPS_PORT" != "22" ]; then
LOOKUP_HOST="[$VPS_HOST]:$VPS_PORT"
fi
if ! ssh-keygen -F "$LOOKUP_HOST" -f "$KNOWN_HOSTS_FILE" >/dev/null; then
echo "::error::Deploy step cannot find a known_hosts entry for $LOOKUP_HOST. Check the Configure SSH step and VPS_SSH_KNOWN_HOSTS secret."
exit 1
fi
SSH_COMMAND=(
ssh
-i "$SSH_KEY_FILE"
-p "$VPS_PORT"
-o BatchMode=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="$KNOWN_HOSTS_FILE"
-o IdentitiesOnly=yes
)
RSYNC_SSH_COMMAND="ssh -i $SSH_KEY_FILE -p $VPS_PORT -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KNOWN_HOSTS_FILE -o IdentitiesOnly=yes"
"${SSH_COMMAND[@]}" \
"$VPS_USER@$VPS_HOST" \
"mkdir -p $REMOTE_RELEASE"
rsync -az --delete \
-e "$RSYNC_SSH_COMMAND" \
./publish/ \
"$VPS_USER@$VPS_HOST:$REMOTE_RELEASE/"
"${SSH_COMMAND[@]}" \
"$VPS_USER@$VPS_HOST" "
set -eu
PREVIOUS=\$(readlink -f /opt/obaki/current 2>/dev/null || true)
rollback() {
if [ -n \"\$PREVIOUS\" ]; then
ln -sfn \"\$PREVIOUS\" /opt/obaki/current
sudo systemctl restart obaki || true
fi
exit 1
}
ln -sfn $REMOTE_RELEASE /opt/obaki/current
if ! sudo systemctl restart obaki; then
rollback
fi
if ! sudo systemctl is-active obaki; then
rollback
fi
for ATTEMPT in \$(seq 1 30); do
if curl --fail --silent --show-error --max-time 2 http://127.0.0.1:5000/health/ready >/dev/null; then
break
fi
if [ \"\$ATTEMPT\" -eq 30 ]; then
rollback
fi
sleep 2
done
CURRENT=\$(readlink -f /opt/obaki/current)
COUNT=0
for RELEASE_DIR in \$(ls -1dt /opt/obaki/releases/*/); do
RELEASE_DIR=\${RELEASE_DIR%/}
if [ \"\$RELEASE_DIR\" = \"\$CURRENT\" ]; then
continue
fi
COUNT=\$((COUNT + 1))
if [ \"\$COUNT\" -gt 5 ]; then
rm -rf \"\$RELEASE_DIR\"
fi
done

Change this line to match your actual project path:

dotnet publish ./src/Obaki.Api/Obaki.Api.csproj \

Change this systemd line if your DLL name is different:

ExecStart=/usr/bin/dotnet /opt/obaki/current/Obaki.Api.dll

Push to master:

Terminal window
git add .
git commit -m "Add Obaki VPS deployment workflow"
git push origin main

Then check GitHub Actions.

After deployment finishes, check the VPS:

Terminal window
sudo systemctl status obaki

View logs:

Terminal window
journalctl -u obaki -f

Test locally on the VPS:

Terminal window
curl http://127.0.0.1:5000

Test through Caddy:

Terminal window
curl https://api.yourdomain.com

List releases:

Terminal window
ls -lah /opt/obaki/releases

Switch back to an older release:

Terminal window
sudo ln -sfn /opt/obaki/releases/OLD_RELEASE_FOLDER /opt/obaki/current
sudo systemctl restart obaki

Check:

Terminal window
sudo systemctl status obaki

Keep only the latest 5 releases:

Terminal window
cd /opt/obaki/releases
ls -1dt */ | tail -n +6 | xargs -r rm -rf

You can add this cleanup later to GitHub Actions after deployment succeeds.

Example:

- name: Clean old releases
run: |
ssh -i ~/.ssh/deploy_key -p "${{ secrets.VPS_PORT }}" \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" "
cd /opt/obaki/releases &&
ls -1dt */ | tail -n +6 | xargs -r rm -rf
"

Section titled “15. Recommended .NET background worker rules”

For Obaki background workers:

Worker 1:
- polls outbox every 2 minutes
- sends emails in small batches
- marks sent / failed
- retries with backoff
Worker 2:
- checks external endpoint every 2 minutes
- stores last seen data
- avoids reprocessing old data

Use PeriodicTimer:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await DoWorkAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Obaki background worker failed.");
}
}
}

Avoid:

while (true)
Task.Delay without cancellation token
loading all rows into memory
sending thousands of emails in one batch
unbounded logs

Use bounded batches.

Example:

Every 2 minutes:
1. Fetch 50-100 pending messages.
2. Mark them as Processing.
3. Send using Resend or SMTP provider.
4. Mark successful messages as Sent.
5. Mark failed messages as Failed.
6. Retry failed messages later with backoff.

Do not send directly inside the public API request.

Good flow:

User action
-> API saves message to database
-> API returns success
-> Outbox worker sends email later

Restart Obaki:

Terminal window
sudo systemctl restart obaki

Check status:

Terminal window
sudo systemctl status obaki

Follow logs:

Terminal window
journalctl -u obaki -f

Check Caddy:

Terminal window
sudo systemctl status caddy

Reload Caddy:

Terminal window
sudo systemctl reload caddy

Check listening ports:

Terminal window
sudo ss -tulpn

Check memory:

Terminal window
free -h

Check disk:

Terminal window
df -h

/etc/obaki/
obaki.env
/opt/obaki/
current -> /opt/obaki/releases/<latest>
releases/
20260704130000-abc1234/
20260704150000-def5678/
shared/
logs/
/etc/systemd/system/
obaki.service
/etc/caddy/
Caddyfile

GitHub repository:

source code
GitHub Actions workflow
appsettings.json
appsettings.Development.json
.env.example

GitHub Secrets:

VPS_HOST
VPS_USER
VPS_PORT
VPS_SSH_KEY
VPS_SSH_KNOWN_HOSTS

VPS environment file:

/etc/obaki/obaki.env

Production secrets:

database connection string
Resend API key
JWT signing secret
external API keys
email sender config

Do not commit production secrets. ssh-copy-id is often not available on Windows. Also, deploy@your-vps-ip is a placeholder. Replace it with your real VPS IP.

Example:

ssh-copy-id -i obaki_deploy_key.pub [email protected]

Not:

ssh-copy-id -i obaki_deploy_key.pub deploy@your-vps-ip If you are on Windows, do it manually

On your PC, open PowerShell where the key files exist.

Show the public key:

Get-Content .\obaki_deploy_key.pub

Copy the full output. It looks like:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… github-actions-obaki

Now SSH into your VPS using your normal VPS user:

ssh root@YOUR_VPS_IP

or:

ssh your-user@YOUR_VPS_IP

Create the deploy user if not yet created:

sudo adduser deploy

Create the SSH folder:

sudo mkdir -p /home/deploy/.ssh sudo nano /home/deploy/.ssh/authorized_keys

Paste the public key there.

Then fix permissions:

sudo chown -R deploy:deploy /home/deploy/.ssh sudo chmod 700 /home/deploy/.ssh sudo chmod 600 /home/deploy/.ssh/authorized_keys

Now exit VPS:

exit

Test from your PC:

ssh -i .\obaki_deploy_key deploy@YOUR_VPS_IP

Example:

ssh -i .\obaki_deploy_key [email protected] If PowerShell says key permissions are too open

Run:

icacls .\obaki_deploy_key /inheritance:r icacls .\obaki_deploy_key /grant:r “$env:USERNAME:R”

Then test again:

ssh -i .\obaki_deploy_key deploy@YOUR_VPS_IP Correct expected result

This should log you into the VPS as:

deploy

Then this command should show:

whoami

Output:

deploy Most likely problem

You either used the placeholder:

your-vps-ip

or Windows does not have:

ssh-copy-id

Manual authorized_keys setup fixes it.