Docker
Run Dulak in a Docker container — one command to build and start, everything isolated from the host. The multi-stage image prebuilds client assets, ships production dependencies only, and keeps the SQLite database in a persistent volume. You’ll need a reverse proxy for HTTPS.
Prerequisites
Section titled “Prerequisites”- Docker and Docker Compose installed on your server (install guide).
- SSH access to the server.
- Your code on GitHub — clone your app repo (replace
<your-repo-url>with your repo URL).
git clone <your-repo-url>cd dulakThe rest of this guide runs from inside the cloned repo directory.
Quick start
Section titled “Quick start”docker compose up -d --build- Multi-stage
Dockerfileonoven/bun:1.4-alpine: client assets are prebuilt in the build stage; the runtime stage ships production dependencies only. ./datavolume keeps the SQLite database across restarts; the healthcheck hits/health(withPRAGMA busy_timeout, concurrent writes wait instead of failing).- The container listens on port 4000 (
EXPOSE 4000).
Environment variables
Section titled “Environment variables”Dulak reads env from .env at startup — same file as bare-metal. Docker
Compose injects it into the container via env_file: .env in
docker-compose.yml:
services: app: build: . ports: - "4000:4000" env_file: .env # ← your .env on the host environment: NODE_ENV: production APP_URL: ${APP_URL:-http://localhost:4000} volumes: - ./data:/app/data # persistent SQLite database restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4000/health"] interval: 30s timeout: 5s retries: 3Step by step
Section titled “Step by step”-
Copy the example file in the project root (on the host, not inside the container):
Terminal window cp .env.example .env -
Edit
.env— set the values for your deployment. The minimum you need to change:PORT=4000NODE_ENV=productionAPP_URL=https://your-domain.comDATABASE_PATH=/app/data/app.sqliteMAIL_DRIVER=resendRESEND_API_KEY=...DATABASE_PATHmust point inside the./datavolume (/app/data/...) so the SQLite file survives container restarts. See Configuration for the full env table. -
.envis never baked into the image..dockerignoreexcludes it — the.envfile stays on the host and is injected at runtime viaenv_file. This means you can rebuild the image without leaking secrets, and different environments (staging, production) just use different.envfiles on the host. -
NODE_ENVandAPP_URLare set indocker-compose.ymlunderenvironment:, not.env. Compose’senvironment:block overridesenv_filevalues, so these two are always correct for the container even if your.envhas different values for local dev. If you need to overrideAPP_URL, set it in.envor pass it at build time:Terminal window APP_URL=https://your-domain.com docker compose up -d --build -
Build and start:
Terminal window docker compose up -d --buildThe first build takes ~30s (installs deps, builds client assets). Subsequent builds are faster thanks to Docker layer caching.
-
Check it’s running:
Terminal window docker compose ps # should show "healthy"docker compose logs -f # tail logscurl http://localhost:4000/health
Changing env later
Section titled “Changing env later”Edit .env on the host, then restart the container (no rebuild needed —
the image doesn’t change, only the runtime env):
docker compose restartIf you changed APP_URL in docker-compose.yml itself, or changed the
compose file structure, use up instead:
docker compose up -dAlternatives: bun build --compile for a single binary, or plain
bun run start behind your process supervisor — it handles SIGTERM
gracefully (drains in-flight requests, closes the DB).
Routine updates
Section titled “Routine updates”After you push code to GitHub, pull and rebuild on the server:
git pulldocker compose up -d --buildcurl http://localhost:4000/health # → {"status":"ok",...}The ./data volume persists across rebuilds — your SQLite database and
uploads are not affected.
Reverse proxy
Section titled “Reverse proxy”The container listens on port 4000 (already mapped in docker-compose.yml).
For HTTPS, put a reverse proxy in front — same setup as bare-metal:
If your proxy runs on the same server (Caddy, Nginx, Cloudflare Tunnel),
change the port mapping in docker-compose.yml to "127.0.0.1:4000:4000"
so port 4000 is not exposed publicly.