Linux VPS (no Docker)
Deploy without Docker: Bun runs the TypeScript server directly, client assets are prebuilt, and a systemd unit + reverse proxy handle restarts, HTTPS, and traffic. If your domain is on Cloudflare, no reverse proxy software is needed on the VPS at all — Cloudflare is the proxy. This guide targets Ubuntu 22.04/24.04.
Architecture
Section titled “Architecture”Internet → Cloudflare edge (TLS) → [tunnel | origin rule → VPS:4000] └→ bun run src/index.ts └→ SQLite + uploads in /opt/dulak/data1. Install Bun
Section titled “1. Install Bun”curl -fsSL https://bun.sh/install | bash # installs to ~/.bunsource ~/.bashrcbun --version2. Create the app user and directory
Section titled “2. Create the app user and directory”Run the app as a dedicated user, not root:
sudo useradd --system --create-home --shell /usr/sbin/nologin dulaksudo mkdir -p /opt/dulak/datasudo chown -R dulak:dulak /opt/dulak3. Get the code
Section titled “3. Get the code”cd /opt/dulaksudo -u dulak git clone https://github.com/maulanashalihin/dulak.git .Or copy the app from your build machine — the server runs TS directly, so
the whole repo (minus .git) works.
4. Install dependencies and build client assets
Section titled “4. Install dependencies and build client assets”sudo -u dulak bash -c 'cd /opt/dulak && bun install'sudo -u dulak bash -c 'cd /opt/dulak && bun run build' # client assets → dist/5. Environment
Section titled “5. Environment”Create /opt/dulak/.env:
PORT=4000NODE_ENV=productionAPP_URL=https://your-domain.comDATABASE_PATH=/opt/dulak/data/app.sqliteMAIL_DRIVER=resendRESEND_API_KEY=...# plus Google OAuth, rate limits, upload limits — see ConfigurationUPLOAD_DIR defaults to ./data/uploads — keep it inside /opt/dulak/data
so it survives deploys and is easy to back up.
6. systemd unit
Section titled “6. systemd unit”/etc/systemd/system/dulak.service:
[Unit]Description=Dulak appAfter=network.target
[Service]Type=simpleUser=dulakWorkingDirectory=/opt/dulakEnvironmentFile=/opt/dulak/.envExecStart=/home/dulak/.bun/bin/bun run src/index.tsRestart=alwaysRestartSec=3
[Install]WantedBy=multi-user.targetThe app handles SIGTERM gracefully (drains in-flight requests, closes the
DB), so systemctl restart is safe mid-traffic.
sudo systemctl daemon-reloadsudo systemctl enable --now dulaksudo systemctl status dulakcurl http://127.0.0.1:4000/health # {"status":"ok",...}7. Reverse proxy
Section titled “7. Reverse proxy”Option A — Cloudflare (recommended, nothing to install)
Section titled “Option A — Cloudflare (recommended, nothing to install)”Your domain is on Cloudflare, so the edge handles TLS and proxying; the VPS only runs Bun. Two ways:
A1. Proxied DNS + origin rule (no extra software):
- DNS: A record
your-domain.com→ VPS IP, proxied (orange cloud). - Dashboard → Rules → Origin Rules → create a rule for
your-domain.comthat rewrites the Host header toyour-domain.com(so the app sees the real host for redirects). - SSL/TLS mode: Full (edge→origin over HTTPS with a Cloudflare Origin CA cert) or Flexible (edge→origin HTTP) — Flexible is fine when the origin port is firewalled to Cloudflare IPs only (see §8).
A2. Cloudflare Tunnel (zero open ports — nothing to install except
cloudflared):
# on the VPScurl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflaredchmod +x /usr/local/bin/cloudflaredcloudflared tunnel login # one-time browser authcloudflared tunnel create dulak# run the tunnel as a service pointing at 127.0.0.1:4000cloudflared service installDashboard → Zero Trust → Networks → Tunnels → add a public hostname
your-domain.com → service http://127.0.0.1:4000. No firewall rules
needed — the tunnel connects outbound.
X-Forwarded-Formatters. Both routes put Cloudflare in front, and the rate limiter keys byX-Forwarded-For/CF-Connecting-IPfirst — only run behind a proxy that sets it, or every client shares the “local” bucket.
Option B — Caddy on the VPS (no Cloudflare)
Section titled “Option B — Caddy on the VPS (no Cloudflare)”/etc/caddy/Caddyfile:
your-domain.com { reverse_proxy 127.0.0.1:4000}sudo systemctl enable --now caddyOption C — Nginx + Certbot (no Cloudflare)
Section titled “Option C — Nginx + Certbot (no Cloudflare)”/etc/nginx/sites-available/dulak:
server { listen 80; server_name your-domain.com; location / { 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_http_version 1.1; }}sudo ln -s /etc/nginx/sites-available/dulak /etc/nginx/sites-enabled/sudo nginx -t && sudo systemctl reload nginxsudo certbot --nginx -d your-domain.com8. Firewall
Section titled “8. Firewall”Cloudflare route (A1 — origin rule)
Section titled “Cloudflare route (A1 — origin rule)”Only Cloudflare IPs may reach the app port:
sudo ufw allow OpenSSH# https://www.cloudflare.com/ips/ — keep this list updatedsudo ufw allow from 173.245.48.0/20 to any port 4000sudo ufw allow from 103.21.244.0/22 to any port 4000# ...add all Cloudflare IP ranges...sudo ufw enableOr bind the app to 127.0.0.1 only (HOST=127.0.0.1) and use A2
(tunnel) — no inbound ports at all.
No Cloudflare (options B/C)
Section titled “No Cloudflare (options B/C)”sudo ufw allow OpenSSHsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw enablePort 4000 must NOT be exposed publicly — only 80/443 reach the app.
9. Updates
Section titled “9. Updates”cd /opt/dulaksudo -u dulak git pullsudo -u dulak bun installsudo -u dulak bun run buildsudo systemctl restart dulakOperational notes
Section titled “Operational notes”- Single instance only. SQLite is single-writer and the rate limiter is in-memory — this guide runs one process. Horizontal scaling is a deliberate swap point (external session/upload stores, Redis limiter).
- Logs:
journalctl -u dulak -f. - Backup:
data/app.sqlite+data/uploads(the uploads directory). - Single binary alternative:
bun build --compileproduces a standalone executable with the same runtime — swap theExecStartfor the binary and prebuilddist/on the build machine.