Skip to content

Deployment

Run timbl anywhere that runs Bun. This page covers the common deployment patterns: Docker, reverse proxies, cloud platforms, and VPS. Pick the one that matches your infrastructure.

Docker

Basic Setup

Create a Dockerfile:

FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --production
COPY . .
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["bun", "index.ts"]

Build and run:

Terminal window
# Build image
docker build -t timbl .
# Run with persistent volumes
docker run -p 3000:3000 \
-v $(pwd)/db:/app/db \
-v $(pwd)/uploads:/app/uploads \
timbl

Docker Compose

version: '3.8'
services:
timbl:
build: .
ports:
- "3000:3000"
volumes:
- ./db:/app/db
- ./uploads:/app/uploads
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_PATH=/app/db/cms.db
- UPLOADS_DIR=/app/uploads

Run with compose:

Terminal window
docker-compose up -d

Reverse Proxy

For production, serve timbl behind a reverse proxy. This handles SSL termination and static file serving.

Caddy

example.com {
reverse_proxy localhost:3000
# Serve uploads directly (faster, no timbl overhead)
handle /uploads/* {
root * /app/uploads
file_server
}
}

Nginx

server {
listen 80;
server_name example.com;
# Uploads - serve directly
location /uploads/ {
alias /app/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# API and everything else - proxy to timbl
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (if needed)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

Traefik

labels:
- "traefik.enable=true"
- "traefik.http.routers.timbl.rule=Host(`example.com`)"
- "traefik.http.routers.timbl.tls.certresolver=letsencrypt"
- "traefik.http.services.timbl.loadbalancer.server.port=3000"

Environment-Specific Configuration

Development

PORT=3000
NODE_ENV=development
DATABASE_PATH=./db/cms.db
UPLOADS_DIR=./uploads
CORS_ORIGIN=*

Production

PORT=3000
NODE_ENV=production
DATABASE_PATH=/data/cms.db
UPLOADS_DIR=/data/uploads
UPLOADS_BASE_PATH=/uploads
CORS_ORIGIN=https://example.com

SSL/TLS

Let’s Encrypt with Caddy

Caddy automatically provisions SSL certificates:

example.com {
reverse_proxy localhost:3000
}

Let’s Encrypt with Certbot + Nginx

Terminal window
# Install certbot
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d example.com
# Auto-renewal is set up automatically

Cloud Deployment

Railway

Terminal window
# Install Railway CLI
npm install -g @railway/cli
# Login and deploy
railway login
railway init
railway up

Add volumes for persistence in Railway dashboard.

Fly.io

Terminal window
# Install flyctl
curl -L https://fly.io/install.sh | sh
# Launch
fly launch
# Create volumes for persistence
fly volumes create timbl_data --region sin --size 1

Update fly.toml:

[mounts]
source="timbl_data"
destination="/app/data"

VPS (DigitalOcean, Linode, etc.)

  1. Provision server with Ubuntu 22.04+
  2. Install Bun: curl -fsSL https://bun.sh/install | bash
  3. Clone your repo
  4. Run bun install
  5. Set up systemd service
  6. Configure Nginx/Caddy as reverse proxy

Systemd Service

Create /etc/systemd/system/timbl.service:

[Unit]
Description=timbl CMS
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/app/timbl
ExecStart=/root/.bun/bin/bun run start
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=DATABASE_PATH=/app/data/cms.db
Environment=UPLOADS_DIR=/app/data/uploads
[Install]
WantedBy=multi-user.target

Enable and start:

Terminal window
sudo systemctl enable timbl
sudo systemctl start timbl
sudo systemctl status timbl

Backup Strategy

Database

SQLite is a single file - easy to backup:

Terminal window
# Daily backup cron job
0 2 * * * cp /app/data/cms.db /backups/cms-$(date +\%Y\%m\%d).db

Uploads

Sync to S3 or similar:

Terminal window
# Using rclone
rclone sync /app/uploads s3:my-bucket/timbl-uploads

Full Backup Script

#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/backups/$DATE
mkdir -p $BACKUP_DIR
# Database
cp /app/data/cms.db $BACKUP_DIR/
# Uploads
tar -czf $BACKUP_DIR/uploads.tar.gz /app/uploads
# Config
cp /app/timbl/content.config.ts $BACKUP_DIR/
# Optional: Upload to S3
aws s3 sync $BACKUP_DIR s3://my-backup-bucket/timbl/$DATE

Health Checks

The runtime registers GET /api/health for process health and GET /api/ready for dependency readiness. Point platform health checks at /api/health and readiness checks at /api/ready.

Monitoring

Structured Logging

timbl uses structured logging. In production, forward logs to your monitoring system:

Terminal window
# Bun automatically outputs JSON in production
bun run start 2>&1 | jq -c '. | select(.level == "error")'

Metrics Endpoint

Add via plugin:

registerRoute({
method: "GET",
path: "/api/metrics",
auth: "session",
handler() {
return {
status: 200,
body: {
uptime: process.uptime(),
memory: process.memoryUsage(),
// add custom metrics
}
};
},
});

Security Checklist

  • Use strong authentication (Better-Auth with secure passwords)
  • Set NODE_ENV=production
  • Restrict CORS_ORIGIN to your domain
  • Run behind HTTPS reverse proxy
  • Regular database backups
  • Upload directory outside web root (or serve via proxy)
  • Keep dependencies updated
  • Enable firewall (ufw/iptables)
  • Use non-root user for timbl process
  • Set up log monitoring

Troubleshooting

Database locked

SQLite doesn’t handle concurrent writes well. If you see “database is locked”:

  • Ensure single timbl instance (no horizontal scaling with SQLite)
  • Check for long-running transactions
  • Consider WAL mode (Write-Ahead Logging)

Uploads not serving

Check UPLOADS_DIR and UPLOADS_BASE_PATH env vars. Ensure directory permissions.

CORS errors

Set CORS_ORIGIN to your actual domain, not * in production.

See also