4.9 KiB
test-app-stack
A minimal full-stack reference project:
- Backend — ElysiaJS running on Bun. Exposes a small JSON API.
- Frontend — TanStack Start (React 19, SSR via Vite) that calls the backend and renders the result.
- Orchestration —
docker-compose.ymlwiring both services behind an existing Traefik 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):
cd backend
bun install
bun run dev # http://localhost:3000
Frontend (requires Node 22+):
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 setVITE_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 namedleand awebsecure(:443) entrypoint. -
DNS: create
Arecords for both hostnames pointing at the VPS IP:test-app.emmanuelariasa.com→ frontendtest-app-api.emmanuelariasa.com→ backend
(Adjust the names via
.env. Without DNS, Traefik can't issue certificates.)
2. Configure
cp .env.example .env
# edit .env if you want different hostnames / CORS origin
3. Build the images
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
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
git pull
docker compose build
docker compose up -d # recreates only changed services
6. Tear down
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.tsis generated on firstvite dev/buildand 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.