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
activationUrlen 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/registerresponde403 ONBOARDING_CLOSEDy 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,
curlo 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
| Variable | Obligatoria | Default | Para qué |
|---|---|---|---|
DATABASE_URL | sí | — | Conexión a PostgreSQL |
JWT_SECRET 🔒 | sí | — | Firma de los JWT (mín. 32 chars; openssl rand -base64 48) |
JWT_ACCESS_EXPIRATION | sí | 15m | Duración del access token |
JWT_REFRESH_EXPIRATION | sí | 7d | Duración del refresh token |
BCRYPT_SALT_ROUNDS | sí | — | Costo de hashing de contraseñas (10–15) |
CACHE_DRIVER | sí | memory | memory o redis |
REDIS_URL | si usás redis | redis://localhost:6379 | Conexión a Redis |
PORT | no | 3000 | Puerto de la API |
API_BASE_URL | no | http://localhost:3000 | Base pública de la API (URLs de subida) |
FRONTEND_URL | no | http://localhost:4321 | URL del front (CORS y redirecciones) |
CORS_ORIGIN | no | = FRONTEND_URL | Orígenes permitidos (separados por coma) |
SINGLE_RESTAURANT_MODE | no | false | Cierra el registro tras el 1er restaurante (self-host: true) |
Cookies / sesión
| Variable | Default | Para qué |
|---|---|---|
COOKIE_SECURE | true | Cookies solo por HTTPS. En HTTP local → false |
COOKIE_DOMAIN | "" | Dominio de las cookies (vacío = host exacto) |
COOKIE_ACCESS_MAX_AGE | 900000 | max-age del access token (ms) |
COOKIE_REFRESH_MAX_AGE | 604800000 | max-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:
| Variable | Default | Para 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:
| Variable | Para qué |
|---|---|
UPLOAD_STORAGE | Poné 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_NAME | Nombre del bucket |
UPLOAD_CF_R2_PUBLIC_URL | URL 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ú:
| Variable | Para qué |
|---|---|
GEMINI_API_KEY 🔒 | API key de Google Gemini |
GEMINI_MODEL | Modelo, 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) yPUBLIC_API_URL(UI) con tu dominio real, y usá cookiesSecuresobre 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.