Linux VPS (no Docker)
Run Dulak directly on a Linux VPS — no Docker, no container overhead. Bun runs the TypeScript server, client assets are prebuilt, and systemd handles restarts. You’ll need a reverse proxy for HTTPS (Cloudflare, Caddy, or Nginx). This guide targets Ubuntu 22.04/24.04/26.04.
Prerequisites
Section titled “Prerequisites”- A Linux VPS with root or sudo access (Ubuntu 22.04/24.04/26.04).
- SSH access to the server.
- Git installed on the server (
sudo apt install gitif missing). - Your code on GitHub — clone your app repo (replace
<your-repo-url>with your repo URL).
Architecture
Section titled “Architecture”Internet → Cloudflare edge (TLS) → [tunnel | origin rule → VPS:4000] └→ bun run src/index.ts └→ SQLite + uploads in /opt/dulak/dataPhase 1 — First-time setup
Section titled “Phase 1 — First-time setup”All commands run on the server via SSH. SSH in first:
ssh root@your-server-ip1.1 Install Bun
Section titled “1.1 Install Bun”curl -fsSL https://bun.sh/install | bashsource ~/.bashrcbun --version # should print 1.4.x or higher1.2 Get the code
Section titled “1.2 Get the code”git clone <your-repo-url> /opt/dulakcd /opt/dulakThe /opt/dulak path is just an example — name it after your app.
Adjust the path in the commands and systemd unit below to match.
Or copy the app from your build machine — the server runs TS directly,
so the whole repo (minus .git) works. The data/ directory is created
automatically on first startup.
1.3 Install dependencies and build
Section titled “1.3 Install dependencies and build”bun installbun run build1.4 Environment
Section titled “1.4 Environment”Create /opt/dulak/.env:
cat > /opt/dulak/.env << 'EOF'PORT=4000NODE_ENV=productionAPP_URL=https://your-domain.comDATABASE_PATH=/opt/dulak/data/app.sqliteMAIL_DRIVER=resendRESEND_API_KEY=...EOFAdjust the values for your deployment — see
Configuration for the full env table.
UPLOAD_DIR defaults to ./data/uploads — keep it inside
/opt/dulak/data so it survives deploys and is easy to back up.
1.5 systemd unit
Section titled “1.5 systemd unit”Create /etc/systemd/system/dulak.service:
sudo tee /etc/systemd/system/dulak.service << 'EOF'[Unit]Description=Dulak appAfter=network.target
[Service]Type=simpleWorkingDirectory=/opt/dulakEnvironmentFile=/opt/dulak/.envExecStart=%h/.bun/bin/bun run src/index.tsRestart=alwaysRestartSec=3
[Install]WantedBy=multi-user.targetEOFEnable and start:
sudo systemctl daemon-reloadsudo systemctl enable --now dulak1.6 Verify
Section titled “1.6 Verify”sudo systemctl is-active dulak # → activecurl http://127.0.0.1:4000/health # → {"status":"ok","uptime":...}If either check fails, see Troubleshooting below.
1.7 Reverse proxy and firewall
Section titled “1.7 Reverse proxy and firewall”Dulak does not terminate TLS — that is the proxy’s job. Set up
Cloudflare, Caddy, or Nginx in front of 127.0.0.1:4000:
Phase 2 — Routine updates
Section titled “Phase 2 — Routine updates”After you push code to GitHub, SSH to the server and run:
cd /opt/dulakgit pullbun installbun run buildsudo systemctl restart dulakcurl http://127.0.0.1:4000/health # → {"status":"ok",...}That’s it — 5 commands, all on the server. The app handles SIGTERM
gracefully (drains in-flight requests, closes the DB), so
systemctl restart is safe mid-traffic.
Troubleshooting
Section titled “Troubleshooting”Service won’t start
Section titled “Service won’t start”sudo journalctl -u dulak -n 30 --no-pagerCommon causes:
Invalid configuration: ...— a required env var is missing or invalid. Check/opt/dulak/.envagainst Configuration. The error message names the exact var.bun: not found— the systemd unit points to%h/.bun/bin/bunbut Bun is installed elsewhere. Check the path:which bun, and updateExecStartin the unit file.EADDRINUSE: Port 4000— another process is using port 4000. Find it:ss -tlnp | grep 4000. Kill it or changePORTin.env.
Health check returns non-200
Section titled “Health check returns non-200”sudo systemctl status dulak # check if activesudo journalctl -u dulak -f # live tail for errorscurl -v http://127.0.0.1:4000/health # verbose responseIf the service is active but /health returns 500, the database may be
locked or the DB file is missing. Check:
ls -la /opt/dulak/data/app.sqlite # file exists?sqlite3 /opt/dulak/data/app.sqlite 'PRAGMA integrity_check;'Port not reachable from outside
Section titled “Port not reachable from outside”The app binds to 0.0.0.0:4000 by default. If you can’t reach it:
- Firewall blocking:
sudo ufw status— if UFW is active, allow the port (or better, set up the reverse proxy and only expose 80/443). - App bound to localhost: if you set
HOST=127.0.0.1, only local processes can reach it. This is correct behind a reverse proxy — the proxy connects locally, the port is not public. - Cloudflare timeout: check that your DNS record points to the right IP and the proxy status is Proxied (orange cloud). See Reverse proxy → Cloudflare.
Permission denied on data directory
Section titled “Permission denied on data directory”sudo chown -R $USER:$USER /opt/dulak/datasudo chmod 755 /opt/dulak/dataThe data directory must be owned by your user — SQLite needs write access
to create the -wal and -shm files alongside the database.
Operational 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.