Construimos una plataforma de automatización para restaurantes: un kiosco para que el cliente ordene solo y un dashboard para que el local gestione productos, menús, órdenes y caja. Ahora es open source y la publicamos como imágenes Docker públicas, así que podés correrla en tu propio servidor sin depender de nuestra nube.

El código está en GitHub: podés usar las imágenes ya construidas (esta guía) o clonar el repo y buildear vos mismo.

Este post es para developers: en cinco minutos tenés el stack andando en tu máquina.

Qué vas a levantar

  • api-core — API REST + SSE (NestJS), sobre PostgreSQL.
  • ui — el frontend (kiosco + dashboard) servido por nginx.
  • PostgreSQL — la base.

Las imágenes son multi-arquitectura (linux/amd64 y linux/arm64), así que corren igual en un PC Intel/AMD o en un servidor ARM / Mac Apple Silicon.

Requisitos

Solo Docker con Compose. Nada más.

Arrancar en 5 minutos

Creá un docker-compose.yml:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: restaurants
    volumes:
      - pgdata:/var/lib/postgresql/data

  api:
    image: ghcr.io/ronnymedina/restaurants-api-core:latest
    # La imagen no corre migraciones sola: las aplicamos antes de arrancar.
    command: ["sh", "-c", "./commands/execute-migrations.sh && node dist/src/main"]
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/restaurants
      JWT_SECRET: ${JWT_SECRET}
      JWT_ACCESS_EXPIRATION: 15m
      JWT_REFRESH_EXPIRATION: 7d
      BCRYPT_SALT_ROUNDS: 12
      CACHE_DRIVER: memory
      SINGLE_RESTAURANT_MODE: "true"
      FRONTEND_URL: http://localhost:8080
      CORS_ORIGIN: http://localhost:8080
      API_BASE_URL: http://localhost:3000
      # En HTTP local las cookies no pueden ser Secure; en producción con HTTPS → "true".
      COOKIE_SECURE: "false"
    depends_on: [db]
    ports:
      - "3000:3000"

  ui:
    image: ghcr.io/ronnymedina/restaurants-ui:latest
    # El front es estático: la URL de la API se inyecta al arrancar el contenedor.
    environment:
      PUBLIC_API_URL: http://localhost:3000
    depends_on: [api]
    ports:
      - "8080:80"

volumes:
  pgdata:

Y un .env al lado:

POSTGRES_PASSWORD=cambia-esta-clave
# Generá un secreto único: openssl rand -base64 48
JWT_SECRET=pegá-acá-un-secreto-de-al-menos-32-chars

Levantás todo:

docker compose up -d

Cuando en los logs de api veas All migrations have been successfully applied y Nest application successfully started, abrí http://localhost:8080 y completá el onboarding de tu restaurante.

¿Sin proveedor de email configurado? El endpoint de registro te devuelve un activationUrl en la respuesta para que actives la cuenta sin salir de casa.

Un local, una instancia: SINGLE_RESTAURANT_MODE

Una instalación self-host es, casi siempre, para un solo restaurante. Por eso agregamos el flag SINGLE_RESTAURANT_MODE:

  • Con la base vacía, el onboarding está abierto: registrás tu restaurante.
  • Apenas existe uno, el registro público se cierra: POST /v1/onboarding/register responde 403 ONBOARDING_CLOSED y la pantalla de onboarding redirige a /login.
  • La validación es de backend (un guard sobre la ruta), no un simple ocultar el botón: da igual si le pegan desde el navegador, curl o Postman.

Si en algún caso excepcional necesitás sumar otro restaurante en la misma instancia, se hace por CLI, nunca reabriendo el endpoint público:

docker compose exec api pnpm run cli create-restaurant --name "Otro Local"

En la nube multi-cliente dejás el flag en false (su valor por defecto) y el onboarding queda abierto como siempre.

Referencia de configuración

El quickstart de arriba trae lo mínimo para arrancar. Estas son todas las variables de api-core, por si querés activar email, subir imágenes a la nube o la IA de productos. Las marcadas con 🔒 son secretos: generá valores propios y nunca los publiques.

Núcleo

VariableObligatoriaDefaultPara qué
DATABASE_URLConexión a PostgreSQL
JWT_SECRET 🔒Firma de los JWT (mín. 32 chars; openssl rand -base64 48)
JWT_ACCESS_EXPIRATION15mDuración del access token
JWT_REFRESH_EXPIRATION7dDuración del refresh token
BCRYPT_SALT_ROUNDSCosto de hashing de contraseñas (10–15)
CACHE_DRIVERmemorymemory o redis
REDIS_URLsi usás redisredis://localhost:6379Conexión a Redis
PORTno3000Puerto de la API
API_BASE_URLnohttp://localhost:3000Base pública de la API (URLs de subida)
FRONTEND_URLnohttp://localhost:4321URL del front (CORS y redirecciones)
CORS_ORIGINno= FRONTEND_URLOrígenes permitidos (separados por coma)
SINGLE_RESTAURANT_MODEnofalseCierra el registro tras el 1er restaurante (self-host: true)

Cookies / sesión

VariableDefaultPara qué
COOKIE_SECUREtrueCookies solo por HTTPS. En HTTP localfalse
COOKIE_DOMAIN""Dominio de las cookies (vacío = host exacto)
COOKIE_ACCESS_MAX_AGE900000max-age del access token (ms)
COOKIE_REFRESH_MAX_AGE604800000max-age del refresh token (ms)

Email con Resend (opcional)

Sin esto, el onboarding devuelve el link de activación en la respuesta. Para mandar los correos de activación de verdad:

VariableDefaultPara qué
RESEND_API_KEY 🔒API key de Resend
EMAIL_FROM[email protected]Remitente de los correos

Imágenes en Cloudflare R2 (opcional)

Por defecto los archivos se guardan en disco local (UPLOAD_STORAGE=local). Para usar Cloudflare R2:

VariablePara qué
UPLOAD_STORAGEPoné r2
UPLOAD_CF_R2_ACCOUNT_ID 🔒Account ID de Cloudflare
UPLOAD_CF_R2_ACCESS_KEY_ID 🔒Access Key del bucket
UPLOAD_CF_R2_SECRET_ACCESS_KEY 🔒Secret Key del bucket
UPLOAD_CF_R2_BUCKET_NAMENombre del bucket
UPLOAD_CF_R2_PUBLIC_URLURL pública del bucket (https://pub-xxxx.r2.dev)

Un paso extra: CORS del bucket. La subida de fotos usa URLs prefirmadas —el navegador sube el archivo directo a R2—, así que el bucket necesita una política CORS que permita tu origen. Sin ella, la subida falla con un error de CORS aunque las credenciales estén bien. En el dashboard de Cloudflare → R2 → tu bucket → Settings → CORS Policy, pegá (ajustá los orígenes a los tuyos):

[
  {
    "AllowedOrigins": ["http://localhost:8080", "https://tu-dominio.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["content-type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

IA de productos con Gemini (opcional)

Con esto, el onboarding extrae los productos desde una foto del menú:

VariablePara qué
GEMINI_API_KEY 🔒API key de Google Gemini
GEMINI_MODELModelo, ej. gemini-1.5-flash

Hay además variables avanzadas con defaults sanos (paginación, tamaño de imágenes, impresión de tickets, OpenTelemetry) que casi nunca hace falta tocar.

Explorá la API

Publicamos la referencia OpenAPI completa (55 endpoints) con Swagger UI:

Referencia interactiva de la API

El spec también está como archivo: restaurants-api-v1.json, por si querés importarlo a Postman/Insomnia o generar un cliente.

Antes de exponerlo a internet

Este compose es para probar en tu red. Para producción real, un par de cosas:

  • Ponelo detrás de tu propio reverse proxy con HTTPS (nginx, Caddy, un túnel de Cloudflare…). No expongas los puertos crudos a internet.
  • Alineá FRONTEND_URL / CORS_ORIGIN (API) y PUBLIC_API_URL (UI) con tu dominio real, y usá cookies Secure sobre HTTPS.
  • Si usás R2 para las fotos, agregá tu dominio real a la política CORS del bucket.

Cierre

Con un docker-compose.yml y dos variables tenés un kiosco de restaurante corriendo en tu infraestructura, con el registro acotado a un solo local por instancia. Simple a propósito. Y al ser open source, podés leer el código, adaptarlo o contribuir.

¿Querés desplegarla en tu restaurante o adaptarla a tu operación? Escribinos y lo charlamos.