Skip to content

Commit 2bd3afd

Browse files
committed
feat(self-host): add Docker SQLite deployment path
Closes #16
1 parent 26de6b4 commit 2bd3afd

37 files changed

Lines changed: 1834 additions & 90 deletions

.dockerignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
**/node_modules
2+
**/.next
3+
**/out
4+
**/.wrangler
5+
.worktrees
6+
.self-host
7+
data
8+
.git
9+
.env*
10+
.tony-menu.local.json

.env.self-host.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
PUBLIC_URL=
2+
ADMIN_USER=admin
3+
ADMIN_EMAIL=admin@example.com
4+
ADMIN_EMAILS=admin@example.com
5+
ADMIN_PASSWORD_HASH=
6+
ORDER_TIME_ZONE=UTC
7+
ALLOWED_HOST_SUFFIXES=localhost
8+
9+
LLM_PROVIDER=openai
10+
LLM_MODEL=gpt-4o-mini
11+
OPENAI_API_KEY=
12+
ANTHROPIC_API_KEY=
13+
DAILY_AI_REQUEST_LIMIT=
14+
15+
CHAT_SESSION_SECRET=change-me
16+
REFRESH_SECRET=change-me

.pii-allowlist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ test\.local
148148
(?i)^tony(-chat)?$
149149
# npm registry tarball URLs in package-lock files are public dependency metadata.
150150
registry[.]npmjs[.]org
151+
^https?://([a-z0-9-]+\.)*example(/.*)?$
152+
^https?://github\.com/sponsors/[a-z0-9-]+$
153+
^https?://www\.patreon\.com/[a-z0-9-]+$
154+
^https?://[a-z0-9-]+\.org/support$
151155

152156
# ── Demo restaurant seed data (backend/src/lib/demo-seed-data.ts) ──
153157
# Placeholder contact details for the public "Trattoria Demo" — all fake.

Caddyfile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
:80 {
2+
@admin path /admin* /api/admin* /api/catalog/publish
3+
basicauth @admin {
4+
{$ADMIN_USER} {$ADMIN_PASSWORD_HASH}
5+
}
6+
7+
handle /api/admin* {
8+
uri strip_prefix /api
9+
reverse_proxy backend:8787 {
10+
header_up X-Forwarded-Email {$ADMIN_EMAIL}
11+
header_up X-Forwarded-Name {$ADMIN_USER}
12+
}
13+
}
14+
15+
handle /api/catalog/publish {
16+
uri strip_prefix /api
17+
reverse_proxy backend:8787 {
18+
header_up X-Forwarded-Email {$ADMIN_EMAIL}
19+
header_up X-Forwarded-Name {$ADMIN_USER}
20+
}
21+
}
22+
23+
handle /api/* {
24+
uri strip_prefix /api
25+
reverse_proxy backend:8787
26+
}
27+
28+
handle /chat/* {
29+
uri strip_prefix /chat
30+
reverse_proxy chat:8788
31+
}
32+
33+
handle /assets/* {
34+
reverse_proxy backend:8787
35+
}
36+
37+
handle {
38+
reverse_proxy web:3000
39+
}
40+
}

Dockerfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM node:22-bookworm
2+
3+
WORKDIR /app
4+
5+
COPY package*.json ./
6+
COPY backend/package*.json backend/
7+
COPY web/package*.json web/
8+
COPY packages/schemas/package.json packages/schemas/
9+
RUN npm ci
10+
11+
COPY web/workers/chat/package*.json web/workers/chat/
12+
RUN cd web/workers/chat && npm ci
13+
14+
COPY . .
15+
16+
ARG NEXT_PUBLIC_API_URL=/api
17+
ARG NEXT_PUBLIC_CHAT_WORKER_URL=/chat
18+
ARG NEXT_PUBLIC_DEFAULT_LOCALE=en
19+
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
20+
NEXT_PUBLIC_CHAT_WORKER_URL=$NEXT_PUBLIC_CHAT_WORKER_URL \
21+
NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
22+
RUN NEXT_IGNORE_INCORRECT_LOCKFILE=1 npm --workspace web run build
23+
24+
EXPOSE 3000 8787 8788
25+
CMD ["node", "scripts/serve-static.mjs", "web/out", "3000"]

README.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# TonyMenu
22

3-
Self-hostable digital restaurant menu, built on Next.js and Cloudflare. Diners scan a QR code, browse a localized menu, and can ask an optional AI assistant for recommendations.
3+
Self-hostable digital restaurant menu, built on Next.js. Cloudflare is the recommended hosted target; Docker + SQLite is available when you do not want a Cloudflare account. Diners scan a QR code, browse a localized menu, and can ask an optional AI assistant for recommendations.
44

55
Restaurant owners manage the menu from `/admin`. QR codes should point to `/`; the app detects the locale and redirects diners to the localized menu.
66

@@ -46,18 +46,29 @@ service, embedding it in a paid product) requires a separate license. See
4646

4747
| Component | What |
4848
|---|---|
49-
| `web/` | Next.js 16 (App Router), deployed to Cloudflare Pages |
50-
| `backend/` | Hono API on Cloudflare Workers, Drizzle ORM over Cloudflare D1 |
51-
| `web/workers/chat/` | Separate Cloudflare Worker for the AI chat assistant with SSE streaming and tool calls |
49+
| `web/` | Next.js 16 (App Router), deployed to Cloudflare Pages or static Docker |
50+
| `backend/` | Hono API on Cloudflare Workers/D1 or Node/SQLite |
51+
| `web/workers/chat/` | Chat service on Cloudflare Workers or Node, with SSE streaming and tool calls |
5252
| `packages/schemas/` | Shared Zod schemas (`@menu/schemas`) |
53-
| Auth | Cloudflare Access with backend JWT verification |
54-
| Storage | Cloudflare R2 for images and catalog snapshots, Cloudflare KV for the chat menu cache |
53+
| Auth | Cloudflare Access, or trusted reverse-proxy header in Docker |
54+
| Storage | R2/KV on Cloudflare, or filesystem uploads + in-memory chat cache in Docker |
5555

5656
## Self-hosting
5757

58-
Full walkthrough: **[docs/self-hosting.md](docs/self-hosting.md)**.
58+
- Cloudflare walkthrough: **[docs/self-hosting.md](docs/self-hosting.md)**.
59+
- No-Cloudflare Docker walkthrough: **[docs/self-hosting-without-cloudflare.md](docs/self-hosting-without-cloudflare.md)**.
5960

60-
Prerequisites: Node 22+, npm 10+, Git, and a Cloudflare account with Zero Trust enabled.
61+
Cloudflare prerequisites: Node 22+, npm 10+, Git, and a Cloudflare account with Zero Trust enabled.
62+
63+
No-Cloudflare quick start:
64+
65+
```bash
66+
cp .env.self-host.example .env
67+
# set ADMIN_PASSWORD_HASH; see docs/self-hosting-without-cloudflare.md
68+
docker compose up --build
69+
```
70+
71+
Then open the proxy on port 8080.
6172

6273
Quick local setup:
6374
```bash
@@ -147,7 +158,8 @@ admin UI changes that require operator action) live in [CHANGELOG.md](CHANGELOG.
147158
## Documentation
148159

149160
- [Changelog](CHANGELOG.md) — per-release notes including upgrade hints
150-
- [Self-hosting guide](docs/self-hosting.md) — deploy your own copy
161+
- [Cloudflare self-hosting guide](docs/self-hosting.md) — deploy your own copy on Cloudflare
162+
- [Docker self-hosting guide](docs/self-hosting-without-cloudflare.md) — deploy without Cloudflare
151163
- [Secrets & env vars](docs/secrets-and-env-vars.md) — full reference
152164
- [Architecture & coding conventions](CLAUDE.md)
153165
- [Contributing](CONTRIBUTING.md)

backend/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# TonyMenu backend
22

3-
Cloudflare Worker REST API for TonyMenu.
3+
REST API for TonyMenu. Runs on Cloudflare Workers/D1 or the self-host Node + SQLite entrypoint.
44

55
## Stack
66

@@ -25,6 +25,7 @@ Cloudflare Worker REST API for TonyMenu.
2525
cd backend
2626
npm install
2727
npm run dev # wrangler dev
28+
npm run start:self-host # Node + SQLite + filesystem bucket
2829
npm run check # tsc --noEmit
2930
npm run test:run # Vitest
3031
npm run deploy # wrangler deploy
@@ -57,6 +58,7 @@ Important bindings/vars:
5758
routes fall back or return a clear 503 where R2 is required.
5859
- `R2_PUBLIC_URL` — public base URL for R2 images.
5960
- `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` — Cloudflare Access JWT verification.
61+
- `SELF_HOST_AUTH_HEADER` — trusted reverse-proxy email header for Node self-host.
6062
- `ALLOWED_ORIGINS` — comma-separated CORS allowlist.
6163
- `OPENAI_API_KEY` — secret used by translation and OpenAI chat flows.
6264

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"type": "module",
66
"scripts": {
77
"dev": "wrangler dev --persist-to .wrangler/state",
8+
"dev:self-host": "tsx src/self-host/server.ts",
9+
"start:self-host": "tsx src/self-host/server.ts",
810
"deploy": "wrangler deploy --var COMMIT_SHA:${COMMIT_SHA:-$(git rev-parse HEAD)} --var ORDER_TIME_ZONE:${ORDER_TIME_ZONE:-$(node -e \"console.log(require('../.tony-menu.local.json').backend.orderTimeZone || 'UTC')\")}",
911
"check": "tsc --noEmit",
1012
"typegen": "wrangler types",
@@ -20,6 +22,7 @@
2022
"test:migration-sql": "grep -E 'CREATE TYPE|jsonb|timestamp with time|numeric\\(' drizzle/*.sql 2>/dev/null && echo 'FAIL: postgres-isms found' && exit 1 || echo 'PASS: SQLite-only SQL'"
2123
},
2224
"dependencies": {
25+
"@hono/node-server": "^1.19.14",
2326
"@menu/schemas": "*",
2427
"@types/better-sqlite3": "^7.6.13",
2528
"better-sqlite3": "^12.9.0",

backend/src/db/index.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
1-
import { drizzle } from 'drizzle-orm/d1';
1+
import { drizzle as drizzleD1 } from 'drizzle-orm/d1';
2+
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
23
import type { Env } from '../types';
34

4-
export function createDb(env: Env) {
5-
if (!env.DB) return null;
6-
return drizzle(env.DB);
5+
type D1Db = ReturnType<typeof drizzleD1>;
6+
7+
function withBatch(db: object): D1Db {
8+
if ('batch' in db) return db as D1Db;
9+
return Object.assign(db, {
10+
async batch(queries: unknown[]) {
11+
const results: unknown[] = [];
12+
for (const query of queries) results.push(await (query as { execute: () => Promise<unknown> }).execute());
13+
return results;
14+
},
15+
}) as D1Db;
16+
}
17+
18+
export function createDb(env: Env): D1Db | null {
19+
if (env.SQLITE_DB) return withBatch(drizzleSqlite(env.SQLITE_DB));
20+
if (env.DB) return drizzleD1(env.DB);
21+
return null;
722
}
823

924
export type DbInstance = NonNullable<ReturnType<typeof createDb>>;

backend/src/lib/env.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const envSchema = z.object({
99
ORDER_TIME_ZONE: z.string().min(1).default('UTC').refine(isTimeZone, 'Invalid time zone'),
1010
ACCESS_TEAM_DOMAIN: z.string().min(1).optional(),
1111
ACCESS_AUD: z.string().min(1).optional(),
12+
SELF_HOST_AUTH_HEADER: z.string().min(1).optional(),
1213
});
1314

1415
function isTimeZone(value: string): boolean {
@@ -29,12 +30,18 @@ export function getRuntimeConfig(env: Env): RuntimeConfig {
2930
serviceName: parsed.SERVICE_NAME,
3031
commitSha: parsed.COMMIT_SHA,
3132
orderTimeZone: parsed.ORDER_TIME_ZONE,
32-
databaseMode: env.DB ? 'd1' : 'unconfigured',
33+
databaseMode: env.SQLITE_DB ? 'sqlite' : env.DB ? 'd1' : 'unconfigured',
3334
hasPublicMenuBucket: Boolean(env.PUBLIC_MENU_BUCKET),
3435
auth: {
3536
issuer: parsed.ACCESS_TEAM_DOMAIN,
3637
audience: parsed.ACCESS_AUD,
37-
configured: Boolean(parsed.ACCESS_TEAM_DOMAIN && parsed.ACCESS_AUD),
38+
trustedHeader: parsed.SELF_HOST_AUTH_HEADER,
39+
mode: parsed.ACCESS_TEAM_DOMAIN && parsed.ACCESS_AUD
40+
? 'cloudflare-access'
41+
: parsed.SELF_HOST_AUTH_HEADER
42+
? 'trusted-header'
43+
: 'unconfigured',
44+
configured: Boolean((parsed.ACCESS_TEAM_DOMAIN && parsed.ACCESS_AUD) || parsed.SELF_HOST_AUTH_HEADER),
3845
},
3946
};
4047
}

0 commit comments

Comments
 (0)