commit a346cf3a5cfea9be08576f671f23fb084d839787 Author: mane Date: Sat Jun 27 04:01:48 2026 +0000 Initial commit: ElysiaJS backend + TanStack Start frontend + Traefik compose Co-Authored-By: Claude Opus 4.8 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..db7b6ef --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and adjust before `docker compose up -d`. +# Public hostnames (must have DNS A records pointing at the VPS). +APP_DOMAIN=test-app.emmanuelariasa.com +API_DOMAIN=test-app-api.emmanuelariasa.com + +# CORS origin the backend will accept (usually the frontend URL). +FRONTEND_ORIGIN=https://test-app.emmanuelariasa.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8da2ac8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# dependencies +node_modules/ + +# build output +.output/ +.vinxi/ +dist/ + +# generated by TanStack Router plugin +frontend/src/routeTree.gen.ts + +# env / secrets +.env +*.local + +# logs & misc +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..517144e --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# test-app-stack + +A minimal full-stack reference project: + +- **Backend** — [ElysiaJS](https://elysiajs.com) running on [Bun](https://bun.sh). Exposes a small JSON API. +- **Frontend** — [TanStack Start](https://tanstack.com/start) (React 19, SSR via Vite) that calls the backend and renders the result. +- **Orchestration** — `docker-compose.yml` wiring both services behind an existing [Traefik](https://traefik.io) reverse proxy with automatic Let's Encrypt TLS. + +``` +test-app-stack/ +├── backend/ # ElysiaJS (Bun) +│ ├── src/index.ts # API: / /api/health /api/hello +│ ├── package.json +│ ├── tsconfig.json +│ └── Dockerfile +├── frontend/ # TanStack Start (React, SSR) +│ ├── src/ +│ │ ├── router.tsx +│ │ └── routes/ +│ │ ├── __root.tsx # HTML shell +│ │ └── index.tsx # Home page — fetches /api/hello +│ ├── vite.config.ts +│ ├── package.json +│ ├── tsconfig.json +│ └── Dockerfile +├── docker-compose.yml # both services + Traefik labels +├── .env.example +└── README.md +``` + +--- + +## API surface (backend) + +| Method | Path | Description | +| ------ | -------------- | --------------------------------------------- | +| GET | `/` | Service banner / metadata | +| GET | `/api/health` | Health probe (`{ status: "healthy", ... }`) | +| GET | `/api/hello` | Demo payload consumed by the frontend | + +The frontend's home page (`frontend/src/routes/index.tsx`) calls `GET /api/hello` +against the URL in `VITE_API_URL` and shows the response — this is the +front ↔ back connection. + +--- + +## Local development + +Two terminals. + +**Backend** (requires [Bun](https://bun.sh)): + +```bash +cd backend +bun install +bun run dev # http://localhost:3000 +``` + +**Frontend** (requires Node 22+): + +```bash +cd frontend +npm install +# point the UI at the local backend +echo 'VITE_API_URL=http://localhost:3000' > .env +npm run dev # http://localhost:3000 (use a different port if backend is on 3000) +``` + +> Both default to port 3000. For local dev run the backend on another port, e.g. +> `PORT=3001 bun run dev`, and set `VITE_API_URL=http://localhost:3001`. + +--- + +## Manual build & deploy (Docker + Traefik) + +This stack does **not** ship its own Traefik. It attaches to the Traefik +instance and the external `web` Docker network already running on the VPS +(from the forgejo stack), and reuses its `le` cert resolver. + +### 1. Prerequisites + +- Docker + Docker Compose on the host. +- An existing Traefik container attached to an external network named `web`, + with a cert resolver named `le` and a `websecure` (:443) entrypoint. +- **DNS**: create `A` records for both hostnames pointing at the VPS IP: + - `test-app.emmanuelariasa.com` → frontend + - `test-app-api.emmanuelariasa.com` → backend + + (Adjust the names via `.env`. Without DNS, Traefik can't issue certificates.) + +### 2. Configure + +```bash +cp .env.example .env +# edit .env if you want different hostnames / CORS origin +``` + +### 3. Build the images + +```bash +docker compose build +``` + +`VITE_API_URL` is passed as a build arg to the frontend because Vite inlines it +into the client bundle at build time — rebuild the frontend if the API hostname +changes. + +### 4. Start + +```bash +docker compose up -d +docker compose ps +docker compose logs -f +``` + +Traefik picks up the containers via their labels and provisions TLS +automatically. Once certificates are issued: + +- Frontend → https://test-app.emmanuelariasa.com +- Backend → https://test-app-api.emmanuelariasa.com/api/health + +### 5. Update / redeploy + +```bash +git pull +docker compose build +docker compose up -d # recreates only changed services +``` + +### 6. Tear down + +```bash +docker compose down # add -v to also drop the app-internal network/volumes +``` + +--- + +## How the pieces connect + +``` +Internet ──TLS──▶ Traefik (:443, existing) + │ Host(test-app.emmanuelariasa.com) → frontend:3000 + │ Host(test-app-api.emmanuelariasa.com) → backend:3000 + ▼ + web (external docker network) + ├── frontend (TanStack Start SSR) + └── backend (ElysiaJS/Bun) ── app-internal ──▶ (future DB) +``` + +The browser loads the SSR'd frontend, then calls the backend directly over its +public HTTPS hostname (`VITE_API_URL`). CORS on the backend is restricted to +`FRONTEND_ORIGIN`. + +--- + +## Notes + +- `frontend/src/routeTree.gen.ts` is generated on first `vite dev`/`build` and is git-ignored. +- Bun lockfile (`bun.lock`) and npm lockfile (`package-lock.json`) are created on first install; commit them for reproducible builds. +- This repository is intentionally deploy-ready but **not auto-deployed** — run the steps above manually. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..96ddfe5 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +.env +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..61fccd1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +# --- Backend: ElysiaJS on Bun --- +FROM oven/bun:1.1-alpine AS deps +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --production --frozen-lockfile || bun install --production + +FROM oven/bun:1.1-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +EXPOSE 3000 +# Lightweight healthcheck hitting the Elysia /api/health route. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 +CMD ["bun", "run", "src/index.ts"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..6d943fe --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "test-app-backend", + "version": "0.1.0", + "description": "ElysiaJS (Bun) backend for test-app-stack", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "elysia": "^1.1.27", + "@elysiajs/cors": "^1.1.1" + }, + "devDependencies": { + "bun-types": "^1.1.34" + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..402fcf8 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,35 @@ +import { Elysia, t } from "elysia"; +import { cors } from "@elysiajs/cors"; + +const PORT = Number(process.env.PORT ?? 3000); + +// CORS: allow the frontend origin. In production set FRONTEND_ORIGIN to the +// public frontend URL; defaults to "*" for local development convenience. +const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN ?? "*"; + +const app = new Elysia() + .use(cors({ origin: FRONTEND_ORIGIN })) + .get("/", () => ({ + service: "test-app-stack backend", + runtime: "ElysiaJS on Bun", + status: "ok", + })) + // Health probe used by Docker / Traefik / monitoring. + .get("/api/health", () => ({ + status: "healthy", + timestamp: new Date().toISOString(), + })) + // Demo endpoint the frontend calls to prove the front<->back connection. + .get("/api/hello", ({ query }) => ({ + message: `Hello${query.name ? `, ${query.name}` : ""} from ElysiaJS + Bun!`, + timestamp: new Date().toISOString(), + }), { + query: t.Object({ name: t.Optional(t.String()) }), + }) + .listen(PORT); + +console.log( + `🦊 Backend running at http://${app.server?.hostname}:${app.server?.port}`, +); + +export type App = typeof app; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..821b798 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a26122 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +# test-app-stack — frontend (TanStack Start) + backend (ElysiaJS/Bun) +# Designed to plug into the EXISTING Traefik instance already running on the +# VPS (the one from forgejo-stack). It therefore reuses the external `web` +# network and the `le` Let's Encrypt cert resolver. No Traefik service is +# defined here on purpose. + +services: + backend: + build: ./backend + container_name: test-app-backend + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + - FRONTEND_ORIGIN=${FRONTEND_ORIGIN:-https://test-app.emmanuelariasa.com} + networks: + - web + - app-internal + labels: + - "traefik.enable=true" + - "traefik.docker.network=web" + - "traefik.http.routers.testapp-api.rule=Host(`${API_DOMAIN:-test-app-api.emmanuelariasa.com}`)" + - "traefik.http.routers.testapp-api.entrypoints=websecure" + - "traefik.http.routers.testapp-api.tls.certresolver=le" + - "traefik.http.services.testapp-api.loadbalancer.server.port=3000" + + frontend: + build: + context: ./frontend + args: + # Baked into the client bundle at build time. + VITE_API_URL: "https://${API_DOMAIN:-test-app-api.emmanuelariasa.com}" + container_name: test-app-frontend + restart: unless-stopped + depends_on: + - backend + environment: + - NODE_ENV=production + - PORT=3000 + networks: + - web + labels: + - "traefik.enable=true" + - "traefik.docker.network=web" + - "traefik.http.routers.testapp.rule=Host(`${APP_DOMAIN:-test-app.emmanuelariasa.com}`)" + - "traefik.http.routers.testapp.entrypoints=websecure" + - "traefik.http.routers.testapp.tls.certresolver=le" + - "traefik.http.services.testapp.loadbalancer.server.port=3000" + +networks: + # Shared with Traefik + forgejo. Created already by the forgejo stack. + web: + external: true + # Private network for any future backend<->db traffic; not exposed. + app-internal: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..26d1d57 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.output +.vinxi +.git +.env +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..aacab06 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +# --- Frontend: TanStack Start (SSR, Node runtime) --- +FROM node:22-alpine AS build +WORKDIR /app +# VITE_API_URL must be present at build time: Vite inlines it into the bundle. +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} +COPY package.json package-lock.json* ./ +RUN npm ci || npm install +COPY . . +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +# TanStack Start (Vite) emits a self-contained server bundle in .output. +COPY --from=build /app/.output ./.output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0a108ce --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "test-app-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "build": "vite build", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-start": "^1.95.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tanstack/router-plugin": "^1.95.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.0" + } +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..78d4340 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,18 @@ +import { createRouter as createTanStackRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; + +// routeTree.gen.ts is auto-generated by the TanStack Router plugin on the +// first `vite dev` / `vite build`. It is git-ignored — do not edit by hand. +export function createRouter() { + return createTanStackRouter({ + routeTree, + defaultPreload: "intent", + scrollRestoration: true, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..53995fa --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,40 @@ +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: "test-app-stack" }, + ], + }), + component: RootComponent, +}); + +function RootComponent() { + return ( + + + + ); +} + +function RootDocument({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + + ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000..9479f55 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +export const Route = createFileRoute("/")({ + component: Home, +}); + +// Public URL of the backend, injected at build time via Vite. +// Falls back to localhost for `vite dev`. +const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000"; + +type Hello = { message: string; timestamp: string }; + +function Home() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(`${API_URL}/api/hello`) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then(setData) + .catch((e) => setError(String(e))); + }, []); + + return ( +
+

test-app-stack

+

TanStack Start frontend talking to an ElysiaJS (Bun) backend.

+ +
+

Backend connection

+

API URL: {API_URL}

+ {error &&

❌ {error}

} + {!error && !data &&

Connecting…

} + {data && ( + <> +

{data.message}

+

at {data.timestamp}

+ + )} +
+
+ ); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6a73d72 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6bd5ee3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; + +// TanStack Start full-stack app. The plugin wires SSR + file-based routing +// (routes live in src/routes, routeTree.gen.ts is generated automatically). +export default defineConfig({ + server: { port: 3000, host: true }, + plugins: [tanstackStart(), viteReact()], +});