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
Clone and configure environment
git clone < your-repo-ur l >
cd restai
cp .env.example .env
Edit .env and set at minimum:
POSTGRES_PASSWORD
JWT_SECRET
JWT_REFRESH_SECRET
CORS_ORIGINS
Build and start services
This will:
Pull PostgreSQL and Redis images
Build API and web images from Dockerfiles
Run database migrations automatically
Start all services with health checks
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:
postgres and redis start first and must pass health checks
migrate runs once after postgres is healthy, then exits
api waits for postgres, redis (healthy) and migrate (completed)
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
All services
API only
Last 100
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:
Missing JWT secrets : Check that JWT_SECRET and JWT_REFRESH_SECRET are set in .env
Database connection : Verify postgres is healthy with docker-compose ps postgres
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:
Missing build-time env vars : Check NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WS_URL in Dockerfile.web:24-25
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:
Remove the postgres service from docker-compose.yml
Set DATABASE_URL in .env to your external database connection string
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