VPS Deployment Guide
Obaki VPS Deployment Guide
Section titled “Obaki 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 APIRecommended stack:
Ubuntu VPSCaddysystemd.NET RuntimeGitHub ActionsSSHrsyncThis is lighter than Docker and fits better on a small VPS such as:
1 vCPU1 GB RAM25 GB disk1. Install .NET Runtime on the VPS
Section titled “1. Install .NET Runtime on the VPS”SSH into your VPS.
ssh your-user@your-vps-ipInstall the .NET runtime required by your API.
For .NET 10, follow the official Microsoft install command for your Ubuntu version.
Example structure:
sudo apt updatesudo apt install -y dotnet-runtime-10.0Verify:
dotnet --infoIf your app is an ASP.NET Core API, install the ASP.NET Core runtime:
sudo apt install -y aspnetcore-runtime-10.02. Create the Obaki app user
Section titled “2. Create the Obaki app user”Create a dedicated Linux user for the app.
sudo adduser --system --group --home /opt/obaki obakiCreate the app folders:
sudo mkdir -p /opt/obaki/releasessudo mkdir -p /opt/obaki/shared/logssudo chown -R obaki:obaki /opt/obakiExpected structure:
/opt/obaki/ releases/ shared/ logs/Later, the deployment will create:
/opt/obaki/current -> /opt/obaki/releases/<release-folder>3. Create the production environment file
Section titled “3. Create the production environment file”Production secrets should live on the VPS, not in GitHub.
Create:
sudo mkdir -p /etc/obakisudo nano /etc/obaki/obaki.envExample:
ASPNETCORE_ENVIRONMENT=ProductionASPNETCORE_URLS=http://127.0.0.1:5000
ConnectionStrings__DefaultConnection=Host=localhost;Port=5432;Database=obaki;Username=obaki;Password=change-this
Resend__ApiKey=re_xxxxxxxxxxxxxxxxxEmail__From=no-reply@yourdomain.com
ExternalProvider__BaseUrl=https://example.com/apiExternalProvider__ApiKey=change-thisUse __ for nested .NET configuration.
Example:
Resend__ApiKey=re_xxxmaps to:
builder.Configuration["Resend:ApiKey"]Example:
ConnectionStrings__DefaultConnection=Host=localhost;Database=obaki;Username=obaki;Password=secretmaps to:
builder.Configuration.GetConnectionString("Default")Lock down the file:
sudo chown root:obaki /etc/obaki/obaki.envsudo chmod 640 /etc/obaki/obaki.envDo not commit .env.
Your repository should only have:
.env.exampleappsettings.jsonappsettings.Development.json4. Create the systemd service
Section titled “4. Create the systemd service”Create the service file:
sudo nano /etc/systemd/system/obaki.servicePaste:
[Unit]Description=Obaki .NET APIAfter=network.target
[Service]WorkingDirectory=/opt/obaki/currentExecStart=/usr/bin/dotnet /opt/obaki/current/Obaki.Api.dllRestart=alwaysRestartSec=5KillSignal=SIGINTSyslogIdentifier=obakiUser=obaki
EnvironmentFile=/etc/obaki/obaki.envEnvironment=DOTNET_GCHeapHardLimitPercent=40
[Install]WantedBy=multi-user.targetImportant:
/opt/obaki/current/Obaki.Api.dllmust match your actual published DLL name.
Examples:
Obaki.Api.dllObaki.Server.dllObaki.WebApi.dllReload systemd:
sudo systemctl daemon-reloadsudo systemctl enable obakiThe app may not start yet because /opt/obaki/current does not exist until the first deployment.
5. Create the API subdomain in Cloudflare
Section titled “5. Create the API subdomain in Cloudflare”Create a subdomain for the Obaki API.
Example:
api.jjosh.devIn Cloudflare:
Cloudflare -> Your domain -> DNS -> Records -> Add recordAdd this DNS record:
Type: AName: apiIPv4 address: your VPS public IPProxy status: DNS onlyTTL: AutoExample:
A api 123.123.123.123This creates:
api.jjosh.devStart with:
DNS onlyThis makes debugging easier because Caddy can directly request and manage the TLS certificate.
After everything works, you can switch the record to:
ProxiedRecommended 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:
FullThen switch back to:
Full (strict)Final DNS flow:
api.jjosh.dev -> Cloudflare DNS -> VPS public IP -> Caddy -> 127.0.0.1:5000 -> Obaki .NET APIDo not expose the .NET API directly to the internet.
Keep Obaki bound to:
127.0.0.1:5000Only Caddy should listen publicly on ports:
804436. Configure Caddy reverse proxy
Section titled “6. Configure Caddy reverse proxy”Edit your Caddyfile:
sudo nano /etc/caddy/CaddyfileExample:
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:
sudo systemctl reload caddyCheck status:
sudo systemctl status caddy7. Create a deploy user
Section titled “7. Create a deploy user”Use a deploy user for GitHub Actions.
sudo adduser deployAdd the deploy user to the obaki group:
sudo usermod -aG obaki deployAllow the deploy user to write to /opt/obaki:
sudo chown -R obaki:obaki /opt/obakisudo chmod -R 775 /opt/obaki8. Allow deploy user to restart only Obaki
Section titled “8. Allow deploy user to restart only Obaki”Open sudoers safely:
sudo visudoAdd this line:
deploy ALL=NOPASSWD: /bin/systemctl restart obaki, /bin/systemctl is-active obaki, /bin/systemctl status obakiThis allows GitHub Actions to restart only the obaki service.
9. Add SSH key for GitHub Actions
Section titled “9. Add SSH key for GitHub Actions”On your local machine, create a deploy SSH key:
ssh-keygen -t ed25519 -C "github-actions-obaki" -f obaki_deploy_keyThis creates:
obaki_deploy_keyobaki_deploy_key.pubCopy the public key to the VPS deploy user:
ssh-copy-id -i obaki_deploy_key.pub deploy@your-vps-ipTest:
ssh -i obaki_deploy_key deploy@your-vps-ipThe private key content goes into GitHub Secrets.
Show the private key:
cat obaki_deploy_keyCopy the full output, including:
-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----10. Add GitHub repository secrets
Section titled “10. Add GitHub repository secrets”In GitHub:
Repository -> Settings -> Secrets and variables -> Actions -> New repository secretAdd:
VPS_HOST=your-vps-ip-or-hostnameVPS_USER=deployVPS_PORT=22VPS_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:
ssh-keyscan -p 22 your-vps-ip-or-hostnameFor a custom SSH port, use the same port as VPS_PORT. The output should start with [host]:port.
ssh-keyscan -p 2222 your-vps-ip-or-hostnameVerify 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.env11. Add GitHub Actions deployment workflow
Section titled “11. Add GitHub Actions deployment workflow”Create this file in your repository:
.github/workflows/deploy.ymlPaste:
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 doneChange 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.dll12. First deployment
Section titled “12. First deployment”Push to master:
git add .git commit -m "Add Obaki VPS deployment workflow"git push origin mainThen check GitHub Actions.
After deployment finishes, check the VPS:
sudo systemctl status obakiView logs:
journalctl -u obaki -fTest locally on the VPS:
curl http://127.0.0.1:5000Test through Caddy:
curl https://api.yourdomain.com13. Rollback
Section titled “13. Rollback”List releases:
ls -lah /opt/obaki/releasesSwitch back to an older release:
sudo ln -sfn /opt/obaki/releases/OLD_RELEASE_FOLDER /opt/obaki/currentsudo systemctl restart obakiCheck:
sudo systemctl status obaki14. Clean old releases
Section titled “14. Clean old releases”Keep only the latest 5 releases:
cd /opt/obaki/releasesls -1dt */ | tail -n +6 | xargs -r rm -rfYou 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 "15. Recommended .NET background worker rules
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 dataUse 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 tokenloading all rows into memorysending thousands of emails in one batchunbounded logs16. Recommended outbox processing
Section titled “16. Recommended outbox processing”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 later17. Useful commands
Section titled “17. Useful commands”Restart Obaki:
sudo systemctl restart obakiCheck status:
sudo systemctl status obakiFollow logs:
journalctl -u obaki -fCheck Caddy:
sudo systemctl status caddyReload Caddy:
sudo systemctl reload caddyCheck listening ports:
sudo ss -tulpnCheck memory:
free -hCheck disk:
df -h18. Final production layout
Section titled “18. Final production layout”/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/ Caddyfile19. What belongs where
Section titled “19. What belongs where”GitHub repository:
source codeGitHub Actions workflowappsettings.jsonappsettings.Development.json.env.exampleGitHub Secrets:
VPS_HOSTVPS_USERVPS_PORTVPS_SSH_KEYVPS_SSH_KNOWN_HOSTSVPS environment file:
/etc/obaki/obaki.envProduction secrets:
database connection stringResend API keyJWT signing secretexternal API keysemail sender configDo 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 deploy@123.123.123.123
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 deploy@123.123.123.123 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.
