diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b40ee7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Keep the Docker build context lean, but DO NOT exclude: +# - attached_assets/ (the web app imports images via the @assets alias) +# - any workspace package.json (pnpm --frozen-lockfile needs them all) + +**/node_modules +**/dist +**/.turbo +**/.vite + +.git +.cache +.config +.local +.canvas +.agents + +*.log +.DS_Store +Thumbs.db + +# Docs / meta not needed inside images +.replitignore diff --git a/.replit b/.replit index 02cca1a..3077950 100644 --- a/.replit +++ b/.replit @@ -31,6 +31,10 @@ externalPort = 8081 localPort = 8082 externalPort = 3003 +[[ports]] +localPort = 9099 +externalPort = 3002 + [[ports]] localPort = 18312 externalPort = 3000 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..355d4fb --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,113 @@ +# Deployment: Replit → Gitea → Mac Mini (Docker) + +This project is developed on **Replit** and self-hosted on a **Mac Mini** using +Docker. **Gitea** is the central Git repository. Nothing deploys directly from +Replit, and GitHub is not used. + +``` +Replit (dev) ──push──▶ Gitea (central repo) ──pull──▶ Mac Mini ──▶ Docker redeploy +``` + +- **Default branch:** `main` +- **Git remote name (everywhere):** `gitea` + +--- + +## What's in this repo + +| File | Runs on | Purpose | +|------|---------|---------| +| `scripts/push-to-gitea.sh` | Replit | Push `main` to the `gitea` remote | +| `deploy.sh` | Mac Mini | Pull `main`, then rebuild & restart Docker | +| `docker-compose.yml` | Mac Mini | Defines the `web` + `api` services | +| `Dockerfile.web` | Mac Mini | Builds the SPA, serves it via nginx (+ `/api` proxy) | +| `Dockerfile.api` | Mac Mini | Builds & runs the Express API server | +| `docker/nginx.conf` | Mac Mini | Static serving + reverse proxy to the API | + +--- + +## One-time setup + +### 1. On Replit — add the `gitea` remote + +The Gitea repo URL is provided later. Add it once (HTTPS with a token, or SSH): + +```bash +# HTTPS (token embedded) — simplest for a headless push +git remote add gitea https://:@//.git + +# …or SSH +git remote add gitea git@:/.git +``` + +Verify: + +```bash +git remote -v # should list 'gitea' +``` + +### 2. On the Mac Mini — clone the repo from Gitea + +```bash +git clone -b main https:////.git ehsan +cd ehsan +# Make sure the remote is named 'gitea' (clone names it 'origin' by default): +git remote rename origin gitea # only if needed +chmod +x deploy.sh +``` + +Requirements on the Mac Mini: **Docker Desktop** (or Docker Engine) with the +`docker compose` plugin. On Apple Silicon, the images build for `linux/amd64` +and run under Rosetta/emulation automatically. + +--- + +## Everyday workflow + +### A. Push from Replit + +Commit your changes (via the Replit Git pane), then: + +```bash +./scripts/push-to-gitea.sh +``` + +(Or directly: `git push gitea main`.) + +### B. Redeploy on the Mac Mini + +```bash +./deploy.sh +``` + +`deploy.sh` will, in order: + +1. `git pull gitea main` +2. `docker compose down` (stop current containers) +3. `docker compose build` (rebuild images) +4. `docker compose up -d` (start again) + +It prints a clear **SUCCESS** message and the running containers, or an +**ERROR** and a non-zero exit code if any step fails. + +After a successful deploy the app is available on the Mac Mini at +`http://localhost:8080` (override the host port with `WEB_PORT`, e.g. +`WEB_PORT=3000 ./deploy.sh`). + +--- + +## Notes & constraints + +- **Replit stays the development environment.** Its workflows/preview are + unchanged by this setup. +- **amd64 / glibc only.** The pnpm workspace strips every native binary that is + not `linux-x64-gnu`, so the Dockerfiles use `node:24-bookworm-slim` (not + alpine) and pin `platform: linux/amd64`. Do not switch the build base to + alpine or arm64 — the web build (rollup / tailwind oxide / lightningcss) will + fail to find its native binaries. +- **Web ↔ API.** The browser calls same-origin `/api/...`; nginx proxies that to + the `api` container, so no API URL needs to be configured in the frontend. +- **Data.** The API currently uses in-memory demo data, so no database service + is included. Restarting the `api` container resets it. +- **Secrets.** Do not commit the Gitea URL/token. Keep it in the local `gitea` + remote (or pass `GITEA_REMOTE_URL` at push time). diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..1c2eabc --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 +# +# API server image (Express, esbuild bundle). +# +# NOTE: the pnpm-workspace `overrides` strip every native binary that is not +# linux-x64-gnu (no musl, no arm64, no darwin). The image therefore MUST be a +# glibc/amd64 image — use node:*-bookworm-slim (NOT alpine) and build for +# linux/amd64 (Docker on Apple Silicon runs this under emulation/Rosetta). + +# ---- Build stage ----------------------------------------------------------- +FROM --platform=linux/amd64 node:24-bookworm-slim AS build + +ENV PNPM_HOME=/pnpm +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@9 --activate + +WORKDIR /repo +COPY . . + +RUN pnpm install --frozen-lockfile +RUN NODE_ENV=production pnpm --filter @workspace/api-server run build + +# ---- Runtime stage --------------------------------------------------------- +FROM --platform=linux/amd64 node:24-bookworm-slim AS runtime + +ENV NODE_ENV=production +WORKDIR /app + +# esbuild produces a self-contained bundle (index.mjs + pino transport files). +COPY --from=build /repo/artifacts/api-server/dist ./dist + +# PORT is supplied by docker-compose (defaults there to 8080). +EXPOSE 8080 +CMD ["node", "--enable-source-maps", "dist/index.mjs"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..377fdbf --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +# +# Web image: builds the Vite static bundle and serves it with nginx, which also +# reverse-proxies /api to the api service. +# +# NOTE: build MUST be glibc/amd64 (see Dockerfile.api for the reason). The +# vite.config.ts requires PORT and BASE_PATH to be set even for `build`. + +# ---- Build stage ----------------------------------------------------------- +FROM --platform=linux/amd64 node:24-bookworm-slim AS build + +ENV PNPM_HOME=/pnpm +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@9 --activate + +WORKDIR /repo +COPY . . + +RUN pnpm install --frozen-lockfile +# PORT + BASE_PATH are required by vite.config.ts at config load time. +RUN PORT=8080 BASE_PATH=/ NODE_ENV=production pnpm --filter @workspace/ehsan-poc run build + +# ---- Runtime stage --------------------------------------------------------- +# The glibc/amd64 constraint applies only to the BUILD stage (node native deps). +# The runtime just serves static files, so the lightweight nginx:alpine is fine. +FROM --platform=linux/amd64 nginx:1.27-alpine AS runtime + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/artifacts/ehsan-poc/dist/public /usr/share/nginx/html + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c253f9b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# EHSAN — Mac Mini deployment script. +# +# Pulls the latest code from the Gitea repository, then rebuilds and restarts +# the Docker containers. Run this ON THE MAC MINI, from the repo checkout. +# +# Flow: Replit -> push to Gitea -> (this script) Mac Mini pulls -> Docker redeploys +# +# Usage: +# ./deploy.sh +# +# Optional overrides (environment variables): +# GIT_REMOTE Git remote to pull from (default: gitea) +# GIT_BRANCH Branch to deploy (default: main) +# WEB_PORT Host port for the web app (default: 8080) + +set -euo pipefail + +GIT_REMOTE="${GIT_REMOTE:-gitea}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +# Always operate from the directory this script lives in. +cd "$(dirname "$0")" + +# --- Resolve the docker compose command (v2 plugin or legacy binary) -------- +if docker compose version >/dev/null 2>&1; then + COMPOSE="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE="docker-compose" +else + echo "ERROR: Docker Compose was not found. Install Docker Desktop (includes the" >&2 + echo " 'docker compose' plugin) or the standalone 'docker-compose' binary." >&2 + exit 1 +fi + +echo "==================================================" +echo " EHSAN deploy | remote=${GIT_REMOTE} branch=${GIT_BRANCH}" +echo "==================================================" + +echo "" +echo "==> [1/4] Pulling latest code from ${GIT_REMOTE}/${GIT_BRANCH}..." +if ! git pull "${GIT_REMOTE}" "${GIT_BRANCH}"; then + echo "ERROR: 'git pull ${GIT_REMOTE} ${GIT_BRANCH}' failed. Is the '${GIT_REMOTE}' remote" >&2 + echo " configured and reachable? (git remote add ${GIT_REMOTE} )" >&2 + exit 1 +fi + +echo "" +echo "==> [2/4] Stopping current containers..." +if ! ${COMPOSE} down --remove-orphans; then + echo "ERROR: Failed to stop the existing containers." >&2 + exit 1 +fi + +echo "" +echo "==> [3/4] Rebuilding containers (this can take a few minutes)..." +if ! ${COMPOSE} build --pull; then + echo "ERROR: Docker image build failed. See the build output above." >&2 + exit 1 +fi + +echo "" +echo "==> [4/4] Starting containers..." +if ! ${COMPOSE} up -d; then + echo "ERROR: Failed to start the containers." >&2 + exit 1 +fi + +echo "" +echo "==================================================" +echo " SUCCESS: Deployment complete." +echo "==================================================" +${COMPOSE} ps +echo "" +echo "The app should now be available on this machine at: http://localhost:${WEB_PORT:-8080}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7a7d364 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# Docker Compose for the EHSAN app on the Mac Mini. +# +# Two services: +# - api : Express API server (internal only, reached via the web proxy) +# - web : nginx serving the built SPA + reverse-proxying /api -> api +# +# Platform is pinned to linux/amd64 because the workspace strips all native +# binaries that are not linux-x64-gnu. On Apple Silicon this runs under +# emulation/Rosetta automatically. + +services: + api: + build: + context: . + dockerfile: Dockerfile.api + platform: linux/amd64 + environment: + NODE_ENV: production + PORT: "8080" + expose: + - "8080" + restart: unless-stopped + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://localhost:8080/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + web: + build: + context: . + dockerfile: Dockerfile.web + platform: linux/amd64 + ports: + # Host port is configurable: WEB_PORT (default 8080) -> container :80 + - "${WEB_PORT:-8080}:80" + depends_on: + api: + condition: service_healthy + restart: unless-stopped diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..08259a8 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip for static assets + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Forward API calls to the api service (same Docker network). + # The prefix /api is preserved, which is what the API server expects. + location /api/ { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA fallback — let the client router handle unknown paths. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/scripts/push-to-gitea.sh b/scripts/push-to-gitea.sh new file mode 100755 index 0000000..659e3de --- /dev/null +++ b/scripts/push-to-gitea.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Push this project to the central Gitea repository. +# +# Run this FROM REPLIT (the development environment). It pushes the `main` +# branch to the `gitea` remote so the Mac Mini can pull and redeploy. +# +# Flow: (this script) Replit -> push to Gitea -> Mac Mini pulls -> Docker redeploys +# +# One-time setup — add the gitea remote with your Gitea repo URL: +# git remote add gitea https://:@//.git +# # or via SSH: +# git remote add gitea git@:/.git +# +# After that, just run: +# ./scripts/push-to-gitea.sh +# +# Optional overrides (environment variables): +# GIT_REMOTE Remote name (default: gitea) +# GIT_BRANCH Branch to push (default: main) +# GITEA_REMOTE_URL If set and the remote does not exist, it is added first. + +set -euo pipefail + +GIT_REMOTE="${GIT_REMOTE:-gitea}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +# Add the remote automatically if a URL was provided and it is missing. +if ! git remote get-url "${GIT_REMOTE}" >/dev/null 2>&1; then + if [ -n "${GITEA_REMOTE_URL:-}" ]; then + echo "==> Adding '${GIT_REMOTE}' remote -> ${GITEA_REMOTE_URL}" + git remote add "${GIT_REMOTE}" "${GITEA_REMOTE_URL}" + else + echo "ERROR: Git remote '${GIT_REMOTE}' is not configured." >&2 + echo " Add it once with:" >&2 + echo " git remote add ${GIT_REMOTE} " >&2 + echo " Or re-run with the URL inline:" >&2 + echo " GITEA_REMOTE_URL= ./scripts/push-to-gitea.sh" >&2 + exit 1 + fi +fi + +# NOTE: never echo the remote URL — it may contain an embedded token. +echo "==> Pushing '${GIT_BRANCH}' to '${GIT_REMOTE}'..." +if git push "${GIT_REMOTE}" "${GIT_BRANCH}"; then + echo "SUCCESS: Pushed ${GIT_BRANCH} to ${GIT_REMOTE}." + echo "Next: on the Mac Mini run ./deploy.sh to pull and redeploy." +else + echo "ERROR: Push to ${GIT_REMOTE}/${GIT_BRANCH} failed." >&2 + exit 1 +fi