Skip to main content
RestAI uses Docker for containerized deployment with separate images for the API server and web frontend. The multi-stage build process optimizes image sizes and ensures production-ready containers.

Architecture Overview

The Docker setup consists of five services:
  • postgres: PostgreSQL 17 Alpine database
  • redis: Redis 7 Alpine for caching and sessions
  • migrate: One-time migration runner using Drizzle ORM
  • api: Bun-based API server (Hono framework)
  • web: Next.js frontend (Node 22 Alpine)

Quick Start

1

Clone and configure environment

git clone <your-repo-url>
cd restai
cp .env.example .env
Edit .env and set at minimum:
  • POSTGRES_PASSWORD
  • JWT_SECRET
  • JWT_REFRESH_SECRET
  • CORS_ORIGINS
2

Build and start services

docker-compose up -d
This will:
  1. Pull PostgreSQL and Redis images
  2. Build API and web images from Dockerfiles
  3. Run database migrations automatically
  4. Start all services with health checks
3

Verify deployment

Check service health:
docker-compose ps
curl http://localhost:3001/health
Access the application:
  • Web UI: http://localhost:3100
  • API: http://localhost:3001

Docker Compose Configuration

The docker-compose.yml defines the complete stack:
services:
  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fenrinegro}
      POSTGRES_DB: restai
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  migrate:
    build:
      context: .
      dockerfile: Dockerfile.api
      target: builder
    working_dir: /app/packages/db
    command: ["bun", "run", "src/migrate.ts"]
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-fenrinegro}@postgres:5432/restai
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"

  api:
    build:
      context: .
      dockerfile: Dockerfile.api
    restart: unless-stopped
    environment:
      API_PORT: "3001"
      DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-fenrinegro}@postgres:5432/restai
      REDIS_URL: redis://redis:6379
      JWT_SECRET: ${JWT_SECRET}
      JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
      CORS_ORIGINS: ${CORS_ORIGINS:-https://app.hosteleria.me}
      LOG_LEVEL: ${LOG_LEVEL:-info}
      R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-}
      R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}
      R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
      R2_BUCKET_NAME: ${R2_BUCKET_NAME:-restai}
      R2_PUBLIC_URL: ${R2_PUBLIC_URL:-}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    ports:
      - "3001:3001"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
      interval: 10s
      timeout: 5s
      start_period: 15s
      retries: 3

  web:
    build:
      context: .
      dockerfile: Dockerfile.web
    restart: unless-stopped
    ports:
      - "3100:3000"
    depends_on:
      api:
        condition: service_healthy

volumes:
  pgdata:
  redisdata:

Multi-Stage Dockerfiles

API Dockerfile

The API uses a 3-stage build process with Bun runtime:
# Stage 1: Install dependencies
FROM oven/bun:1.3.8-alpine AS base
WORKDIR /app

# Copy workspace root files
COPY package.json bun.lock turbo.json ./

# Copy ALL workspace package.json files (bun.lock expects full workspace)
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/types/package.json packages/types/package.json
COPY packages/ui/package.json packages/ui/package.json
COPY packages/validators/package.json packages/validators/package.json

RUN bun install --frozen-lockfile --production

# Stage 2: Source
FROM base AS builder
WORKDIR /app

# Copy source code (workspace packages + api)
COPY packages/ packages/
COPY apps/api/ apps/api/

# Stage 3: Production runner
FROM oven/bun:1.3.8-alpine AS runner
WORKDIR /app

# Non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

# Copy node_modules + source
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/api ./apps/api
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/turbo.json ./turbo.json

USER appuser

EXPOSE 3001

HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
  CMD wget -qO- http://localhost:3001/health || exit 1

CMD ["bun", "run", "apps/api/src/index.ts"]
The API image uses Bun 1.3.8 for fast startup times and efficient TypeScript execution. The production stage runs as non-root user appuser (UID 1001) for security.

Web Dockerfile

The web frontend uses a 3-stage build with Next.js standalone output:
# Stage 1: Install dependencies
FROM oven/bun:1.3.8-alpine AS base
WORKDIR /app

# Copy workspace root files
COPY package.json bun.lock turbo.json ./

# Copy ALL workspace package.json files
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/types/package.json packages/types/package.json
COPY packages/ui/package.json packages/ui/package.json
COPY packages/validators/package.json packages/validators/package.json

RUN bun install --frozen-lockfile

# Stage 2: Build
FROM base AS builder
WORKDIR /app

# Build-time env (baked into client JS)
ENV NEXT_PUBLIC_API_URL=https://api.hosteleria.me
ENV NEXT_PUBLIC_WS_URL=wss://api.hosteleria.me

# Copy source code (workspace packages + web)
COPY packages/ packages/
COPY apps/web/ apps/web/

# Ensure public dir exists (may be empty)
RUN mkdir -p apps/web/public

# Build Next.js (standalone output)
RUN cd apps/web && bunx next build

# Stage 3: Production runner
FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

# Copy standalone server
COPY --from=builder /app/apps/web/.next/standalone ./
# Copy static assets
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
# Copy public folder if it exists
COPY --from=builder --chown=appuser:appgroup /app/apps/web/public ./apps/web/public

USER appuser

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "apps/web/server.js"]
The NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WS_URL environment variables are baked into the client bundle at build time. For production deployments, override these in your CI/CD pipeline or use Docker build args:
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.yourrestaurant.com \
  --build-arg NEXT_PUBLIC_WS_URL=wss://api.yourrestaurant.com \
  -f Dockerfile.web -t restai-web .

Service Dependencies

The startup sequence is orchestrated using health checks:
  1. postgres and redis start first and must pass health checks
  2. migrate runs once after postgres is healthy, then exits
  3. api waits for postgres, redis (healthy) and migrate (completed)
  4. web waits for api to be healthy

Production Deployment

Build for production

# Build images with production tags
docker-compose build --no-cache

# Tag for registry
docker tag restai-api:latest your-registry.com/restai-api:v1.0.0
docker tag restai-web:latest your-registry.com/restai-web:v1.0.0

# Push to registry
docker push your-registry.com/restai-api:v1.0.0
docker push your-registry.com/restai-web:v1.0.0

Environment configuration

Create a production .env file (never commit this to git):
# PostgreSQL
POSTGRES_USER=restai_prod
POSTGRES_PASSWORD=<strong-random-password>

# JWT Secrets (use `openssl rand -base64 32`)
JWT_SECRET=<random-64-char-string>
JWT_REFRESH_SECRET=<random-64-char-string>

# CORS (your production domain)
CORS_ORIGINS=https://app.yourrestaurant.com

# Cloudflare R2 Storage
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=restai-prod
R2_PUBLIC_URL=https://cdn.yourrestaurant.com

# Logging
LOG_LEVEL=warn

Deploy to production

# Pull latest images
docker-compose pull

# Start services in detached mode
docker-compose up -d

# View logs
docker-compose logs -f api web

# Check service status
docker-compose ps

Monitoring and Maintenance

Health checks

All services include health checks:
# Check API health
curl http://localhost:3001/health

# Inspect container health
docker inspect --format='{{.State.Health.Status}}' restai-api-1

View logs

docker-compose logs -f

Database backups

# Backup database
docker-compose exec postgres pg_dump -U restai_prod restai > backup-$(date +%Y%m%d).sql

# Restore database
cat backup-20260302.sql | docker-compose exec -T postgres psql -U restai_prod restai

Update deployment

# Pull latest changes
git pull origin main

# Rebuild and restart services
docker-compose up -d --build

# Remove old images
docker image prune -f

Troubleshooting

Migration fails

If the migrate service fails:
# Check migration logs
docker-compose logs migrate

# Manually run migrations
docker-compose run --rm migrate

API won’t start

Common issues:
  1. Missing JWT secrets: Check that JWT_SECRET and JWT_REFRESH_SECRET are set in .env
  2. Database connection: Verify postgres is healthy with docker-compose ps postgres
  3. Port conflicts: Ensure port 3001 is not in use by another process
# Check API logs
docker-compose logs api

# Restart API service
docker-compose restart api

Web build failures

If the web build fails:
  1. Missing build-time env vars: Check NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WS_URL in Dockerfile.web:24-25
  2. Out of memory: Increase Docker memory limit (Docker Desktop settings)
# Rebuild web service only
docker-compose build --no-cache web

Advanced Configuration

Custom Redis memory limit

Edit the redis command in docker-compose.yml:
redis:
  command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru

Database connection pooling

For high-traffic deployments, configure PostgreSQL connection limits:
postgres:
  environment:
    POSTGRES_USER: ${POSTGRES_USER}
    POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    POSTGRES_DB: restai
  command: postgres -c max_connections=200 -c shared_buffers=256MB

External database

To use an external PostgreSQL instance:
  1. Remove the postgres service from docker-compose.yml
  2. Set DATABASE_URL in .env to your external database connection string
  3. Update the migrate and api service dependencies
When using external databases, ensure you run migrations manually before starting the API:
DATABASE_URL=postgresql://user:pass@external-db:5432/restai bun run packages/db/src/migrate.ts

Build docs developers (and LLMs) love