← Back to essays
7h ago11 min read43 viewsExport Markdown

How I'd Run a One-Person Internet Company on One Server

by Hrvoje Pavlinovic

InfrastructureSelf HostingCloudflareAI CodingDeveloper Tools

The thing I keep coming back to is simple: AI makes a one-person company feel a lot less lonely operationally. You still need real infrastructure, but the gaps that used to require another engineer are getting small enough to cover with a clear setup and a good agent.

Vercel, Supabase, Railway, Fly, and similar platforms are still great. I would use them when speed matters more than control, when the product is simple, or when I do not want to own operations yet. The downside is that a portfolio of projects can slowly turn into many dashboards, many pricing surfaces, many deployment models, and several places where logs, secrets, databases, queues, cron jobs, and backups all behave differently.

That is the tradeoff this setup optimizes for. I want a boring server that can run several products with predictable cost, direct access to logs and data, local background workers, explicit backups, and one operational model I can understand at 1 AM. AI makes that more realistic because Codex can inspect the same Linux primitives I would inspect manually: systemd units, Caddy config, release directories, database dumps, timers, logs, disk usage, and smoke checks.

Target architecture

The public internet should only reach Cloudflare and Caddy. Apps bind to localhost. PostgreSQL and Redis stay private. Deployments are release-based. Important databases are backed up to Cloudflare R2.

Visitor
  -> Cloudflare DNS/CDN
  -> Hetzner VPS public IP
  -> Caddy on :80/:443
      -> static app: /var/www/apps/name/current
      -> dynamic app: 127.0.0.1:PORT
  -> PostgreSQL on 127.0.0.1:5432
  -> Redis on 127.0.0.1:6379
  -> R2 backups through rclone

This is not trying to imitate a large platform. It is a small production system with clear boundaries.

Why not just Vercel and Supabase?

I like both products. They are excellent defaults for many apps. But for a one-person company with several projects, their tradeoffs become more visible.

  • Cost shape: usage-based pricing is convenient until several small projects each have their own database, bandwidth, storage, function, image, and logging profile.
  • Runtime shape: serverless is great for request/response work, but long-running workers, local queues, filesystem workflows, custom binaries, and persistent processes can become awkward.
  • Operational fragmentation: every platform has its own logs, secrets, deploy model, cron behavior, database backup story, and incident workflow.
  • Data gravity: once a product has database, storage, auth, edge functions, webhooks, and scheduled jobs spread across services, moving it later is harder.
  • Debugging path: on a Linux box I can inspect the process, port, journal, disk, config, environment, and database directly.

The downside of owning the server is also real. I own updates, hardening, backups, monitoring, and recovery. The point is not that a VPS is always better. The point is that AI makes the operational cost of owning one lower than it used to be, especially when the setup is boring and documented.

Base server bootstrap

I would start with Ubuntu LTS on Hetzner, then install only the boring core packages.

apt update
apt upgrade -y
apt install -y caddy postgresql redis-server ufw fail2ban rclone git rsync jq htop ncdu curl

Then lock down the firewall to the minimum public surface.

ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose

PostgreSQL and Redis should not be public. For most solo products, localhost networking is enough.

ss -ltnp | grep -E ':(5432|6379)'
# expected shape:
# 127.0.0.1:5432
# 127.0.0.1:6379

Directory layout

I separate static and dynamic apps because their deployment and runtime concerns are different.

/var/www/apps/<static-app>/releases/<build-id>/
/var/www/apps/<static-app>/current -> releases/<build-id>

/srv/apps/<dynamic-app>/releases/<build-id>/
/srv/apps/<dynamic-app>/current -> releases/<build-id>
/etc/<dynamic-app>/<dynamic-app>.env
/var/lib/<dynamic-app>/
/var/log/<dynamic-app>/

The symlink is the deployment boundary. A deploy uploads a new release, validates it, switches current, restarts or reloads what is needed, and prunes old releases.

Caddy config

A static app can be served directly from its current release.

example.com {
  root * /var/www/apps/example/current
  encode zstd gzip

  @static {
    path *.css *.js *.png *.jpg *.jpeg *.webp *.svg *.woff2
  }
  header @static Cache-Control "public, max-age=31536000, immutable"

  try_files {path} /index.html
  file_server
}

A dynamic app should listen only on localhost. Caddy is the public reverse proxy.

app.example.com {
  encode zstd gzip
  reverse_proxy 127.0.0.1:3001
}

Before every reload, validate the config.

caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

Static deploy script

This is the basic pattern for static apps. GitHub Actions can build the site, rsync the output into a new release directory, and call a server-side script like this.

#!/usr/bin/env bash
set -euo pipefail

APP="example"
BASE="/var/www/apps/$APP"
RELEASE="$1"
KEEP=5

if [ ! -d "$BASE/releases/$RELEASE" ]; then
  echo "missing release: $BASE/releases/$RELEASE" >&2
  exit 1
fi

ln -sfn "$BASE/releases/$RELEASE" "$BASE/current"
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

find "$BASE/releases" -maxdepth 1 -mindepth 1 -type d | sort -r | tail -n +$((KEEP + 1)) | xargs -r rm -rf

The cleanup is not optional. Old releases and failed deploys are one of the easiest ways to waste disk.

Dynamic app systemd unit

For dynamic apps, systemd is enough. I want restart behavior, resource limits, log integration, and a small amount of process hardening.

[Unit]
Description=Example app
After=network.target postgresql.service redis-server.service
Wants=postgresql.service redis-server.service

[Service]
Type=simple
User=example
Group=example
WorkingDirectory=/srv/apps/example/current
EnvironmentFile=/etc/example/example.env
ExecStart=/usr/bin/node /srv/apps/example/current/server.js
Restart=on-failure
RestartSec=5

MemoryHigh=512M
MemoryMax=768M
CPUQuota=80%
TasksMax=128

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/srv/apps/example /var/lib/example /var/log/example

LogRateLimitIntervalSec=30
LogRateLimitBurst=2000

[Install]
WantedBy=multi-user.target

Then enable it.

systemctl daemon-reload
systemctl enable --now example.service
systemctl status example.service --no-pager
journalctl -u example.service -n 100 --no-pager

Dynamic deploy script

A dynamic deploy should switch the release, restart the service, and run a health check before declaring success.

#!/usr/bin/env bash
set -euo pipefail

APP="example"
BASE="/srv/apps/$APP"
RELEASE="$1"
KEEP=5

ln -sfn "$BASE/releases/$RELEASE" "$BASE/current"
systemctl restart "$APP.service"

for i in {1..20}; do
  if curl -fsS "http://127.0.0.1:3001/api/health" >/dev/null; then
    break
  fi
  sleep 1
  if [ "$i" = "20" ]; then
    journalctl -u "$APP.service" -n 80 --no-pager
    exit 1
  fi
done

find "$BASE/releases" -maxdepth 1 -mindepth 1 -type d | sort -r | tail -n +$((KEEP + 1)) | xargs -r rm -rf

PostgreSQL per app

Each serious app should get its own database and role. The exact permissions depend on the app, but the default should be narrow.

sudo -u postgres createuser example_user
sudo -u postgres createdb example_prod --owner=example_user
sudo -u postgres psql -c "ALTER USER example_user WITH PASSWORD 'use-a-generated-password';"

The app receives a localhost connection string through its environment file.

DATABASE_URL=postgresql://example_user:[email protected]:5432/example_prod
REDIS_URL=redis://127.0.0.1:6379/0
PORT=3001
HOST=127.0.0.1

Backups to R2

For important apps, backups should be automatic, off-server, and testable. I like a simple systemd timer that runs a script and uploads compressed dumps to R2 through rclone.

#!/usr/bin/env bash
set -euo pipefail

APP="example"
DB="example_prod"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
OUT="/var/backups/$APP/$DB-$STAMP.sql.gz"
REMOTE="r2:server-backups/$APP/postgres/"

mkdir -p "/var/backups/$APP"
sudo -u postgres pg_dump "$DB" | gzip -9 > "$OUT"
rclone copy "$OUT" "$REMOTE"
find "/var/backups/$APP" -type f -name '*.sql.gz' -mtime +7 -delete

The timer can run daily.

[Unit]
Description=Backup example database to R2

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target

And the restore path should be tested before trusting it.

rclone copy r2:server-backups/example/postgres/example_prod-20260715T031500Z.sql.gz /tmp/
createdb restore_test
gunzip -c /tmp/example_prod-20260715T031500Z.sql.gz | psql restore_test
psql restore_test -c 'select count(*) from users;'

What belongs in GitHub

The repo should contain everything needed to understand, test, build, and deploy the app, but not the production secrets. I want a future me or an AI agent to open the repo and know exactly how production is supposed to work.

  • Commit to the repo: app source, lockfiles, migrations, tests, build scripts, .github/workflows/deploy.yml, deploy helper scripts, Caddy examples, systemd unit examples, health-check scripts, and .env.example.
  • Store in GitHub Secrets: deploy host, deploy user, private deploy key, known hosts, and narrowly scoped provider tokens only when CI truly needs them.
  • Keep on the server: production /etc/app/app.env, database passwords, R2 rclone config, Caddyfile, systemd units, backup scripts, and the forced-command deploy scripts.
  • Do not store anywhere public: root SSH keys, database dumps, full production env files, Cloudflare global API keys, or broad personal tokens.

The important split is this: GitHub knows how to build and request a deploy. The server knows the production runtime secrets.

GitHub Actions CI/CD

The CI process should be boring and repeatable. On every push to main, run checks, build an artifact, upload a release directory to the server, switch the symlink, reload or restart the service, run smoke checks, and prune old releases.

name: Deploy example

on:
  push:
    branches: [main]

concurrency:
  group: deploy-example-production
  cancel-in-progress: false

jobs:
  test-build-deploy:
    runs-on: ubuntu-latest
    env:
      APP_NAME: example
      RELEASE: ${{ github.run_number }}-${{ github.sha }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run lint --if-present
      - run: npm test --if-present
      - run: npm run build

      - name: Install SSH key
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh
          printf '%s\\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Upload static release
        run: |
          ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "mkdir -p /var/www/apps/$APP_NAME/releases/$RELEASE"
          rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" dist/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/var/www/apps/$APP_NAME/releases/$RELEASE/"

      - name: Activate release
        run: |
          ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "deploy-static $APP_NAME $RELEASE ${{ github.sha }}"

      - name: Public smoke check
        run: curl -fsS https://example.com/ >/dev/null

For a dynamic app, the build artifact can be a tarball instead of a static dist/ folder. The deploy step extracts it into /srv/apps/app/releases/release-id, switches current, restarts systemd, and checks /api/health before returning success.

GitHub Actions with limited server access

A GitHub Actions deploy key should not become a full shell into the server. The cleaner version is a dedicated deploy user and a forced command. The forced command receives the requested action through SSH_ORIGINAL_COMMAND, validates it, and only allows known deploy operations.

command="/usr/local/bin/deploy-gate example",no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA... github-actions-example
#!/usr/bin/env bash
set -euo pipefail

APP="$1"
ORIGINAL="${SSH_ORIGINAL_COMMAND:-}"

case "$ORIGINAL" in
  deploy-static "$APP" *|deploy-dynamic "$APP" *)
    exec /usr/local/bin/$ORIGINAL
    ;;
  mkdir -p /var/www/apps/$APP/releases/*)
    exec $ORIGINAL
    ;;
  *)
    echo "refusing command: $ORIGINAL" >&2
    exit 1
    ;;
esac

In practice, you can choose how strict to make this. The minimum is a deploy user with no password and a key used only by GitHub Actions. The better version is one key for uploads restricted to the release directory and one key for activation restricted to the deploy script.

Smoke checks

Every app should have a boring health check, and the server should have a small smoke script for reboots and deploys.

#!/usr/bin/env bash
set -euo pipefail

systemctl is-active --quiet caddy
systemctl is-active --quiet postgresql
systemctl is-active --quiet redis-server
systemctl is-active --quiet example.service

curl -fsS https://example.com/ >/dev/null
curl -fsS https://app.example.com/api/health >/dev/null

df -h /
free -h
journalctl -p warning -n 50 --no-pager

This is exactly the kind of thing Codex is good at running and interpreting from a phone-triggered session, because the checks are explicit and the system has a simple shape.

Where Wasp fits

For full-stack TypeScript apps, I would seriously look at Wasp.

The reason is practical: solo-built products often need the same pieces again and again. Auth, database models, server actions, jobs, client routes, and deployment boundaries. Wasp gives that a coherent app model instead of making every new product start from a pile of framework decisions.

It fits this server model well. Build the app with Wasp, use PostgreSQL, run the server as a systemd service, put Caddy in front, keep secrets outside git, and add the same backups, limits, logs, and smoke checks.

The AI operations layer

My actual workflow is not mobile SSH. It is ChatGPT on my phone, Codex on my always-on home laptop, and SSH from that laptop to the Hetzner server.

The mobile app matters because it is not only text input. I can dictate a long operational request while walking, paste an error, attach a screenshot from Cloudflare or GitHub Actions, send a photo of something I am seeing, or drop a screenshot of a broken UI. That context goes into the conversation, while the actual production access stays on the laptop.

The laptop stays awake, has the repos, SSH keys, notes, browser sessions, and local context, and acts as the trusted operator machine. From the phone, I can ask Codex to check disk usage, inspect a failing service, validate backups, review logs, compare a screenshot against the deployed UI, or prepare a deploy. The server only sees SSH from the workstation I already use for development.

That separation is the unlock. The phone is the capture and command surface. The laptop is the execution environment. The server remains a normal Linux server with normal logs, units, files, and ports.

What this setup buys

  • Predictable monthly infrastructure cost.
  • One deployment model for many products.
  • Direct access to processes, logs, disk, and databases.
  • Local workers, queues, cron jobs, and custom binaries without fighting serverless constraints.
  • Explicit resource limits so one app cannot quietly consume the box.
  • Off-server backups that can be restored.
  • A system simple enough for AI-assisted operations to reason about.

The goal is not to become a full-time sysadmin. The goal is to run the infrastructure for a one-person company with enough discipline that it does not become fragile.

Platforms are still useful. Managed services are still useful. But the combination of a boring VPS, Cloudflare, systemd, Caddy, PostgreSQL, Redis, R2 backups, and Codex creates a very strong default for someone who wants control, low fixed costs, and enough operational leverage to run more than one product alone.