Skip to content

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.

  • 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).
Terminal window
git clone <your-repo-url>
cd dulak

The rest of this guide runs from inside the cloned repo directory.

Terminal window
docker compose up -d --build
  • Multi-stage Dockerfile on oven/bun:1.4-alpine: client assets are prebuilt in the build stage; the runtime stage ships production dependencies only.
  • ./data volume keeps the SQLite database across restarts; the healthcheck hits /health (with PRAGMA busy_timeout, concurrent writes wait instead of failing).
  • The container listens on port 4000 (EXPOSE 4000).

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: 3
  1. Copy the example file in the project root (on the host, not inside the container):

    Terminal window
    cp .env.example .env
  2. Edit .env — set the values for your deployment. The minimum you need to change:

    PORT=4000
    NODE_ENV=production
    APP_URL=https://your-domain.com
    DATABASE_PATH=/app/data/app.sqlite
    MAIL_DRIVER=resend
    RESEND_API_KEY=...

    DATABASE_PATH must point inside the ./data volume (/app/data/...) so the SQLite file survives container restarts. See Configuration for the full env table.

  3. .env is never baked into the image. .dockerignore excludes it — the .env file stays on the host and is injected at runtime via env_file. This means you can rebuild the image without leaking secrets, and different environments (staging, production) just use different .env files on the host.

  4. NODE_ENV and APP_URL are set in docker-compose.yml under environment:, not .env. Compose’s environment: block overrides env_file values, so these two are always correct for the container even if your .env has different values for local dev. If you need to override APP_URL, set it in .env or pass it at build time:

    Terminal window
    APP_URL=https://your-domain.com docker compose up -d --build
  5. Build and start:

    Terminal window
    docker compose up -d --build

    The first build takes ~30s (installs deps, builds client assets). Subsequent builds are faster thanks to Docker layer caching.

  6. Check it’s running:

    Terminal window
    docker compose ps # should show "healthy"
    docker compose logs -f # tail logs
    curl http://localhost:4000/health

Edit .env on the host, then restart the container (no rebuild needed — the image doesn’t change, only the runtime env):

Terminal window
docker compose restart

If you changed APP_URL in docker-compose.yml itself, or changed the compose file structure, use up instead:

Terminal window
docker compose up -d

Alternatives: 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).

After you push code to GitHub, pull and rebuild on the server:

Terminal window
git pull
docker compose up -d --build
curl http://localhost:4000/health # → {"status":"ok",...}

The ./data volume persists across rebuilds — your SQLite database and uploads are not affected.

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:

Reverse proxy guide

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.