Rebuild deploy process on the project template

- Dockerfile: deps → builder → pb-downloader → runner stages, cross-arch
  (BUILDPLATFORM node stages, TARGETARCH PocketBase binary), Debian slim
  runner, VOLUME /app/pb_data, dual healthcheck, docker-entrypoint.sh
- docker-entrypoint.sh: bash supervisor replacing scripts/run-all.mjs
- Makefile: build-production (multi-arch push from git remote),
  build-check, build-check-local
- deploy.sh [tag]: make build-production then ansible-playbook with tag
- drop .env/.env.example (credentials live in ansible/vars/default.yml)
This commit is contained in:
2026-09-03 10:24:48 +05:30
parent 757ec8aee2
commit 0ff90cde27
9 changed files with 202 additions and 212 deletions
+5 -1
View File
@@ -16,5 +16,9 @@ app/dist
# Ops assets not needed inside the image # Ops assets not needed inside the image
ansible ansible
scripts/create-collections.sh ansible-sample
Dockerfile-sample
deploy-sample.sh
Makefile
README.md README.md
scripts/create-collections.sh
-8
View File
@@ -1,8 +0,0 @@
# Copy to .env and fill in real values. .env is git-ignored.
REGISTRY=registry.tanshu.com
REGISTRY_USER=ta-registry
REGISTRY_PASSWORD=changeme
# Initial PocketBase admin (applied on first container start; idempotent)
PB_SUPERUSER_EMAIL=admin@greatbear.in
PB_SUPERUSER_PASSWORD=changeme
+2
View File
@@ -26,3 +26,5 @@ ansible-sample/
*.log *.log
.idea/ .idea/
.vscode/ .vscode/
Dockerfile-sample
deploy-sample.sh
+67 -40
View File
@@ -1,61 +1,88 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# The Great Bear — single image running TanStack Start (site) + PocketBase (CMS/API)
ARG NODE_VERSION=22 # ------------------------------------------------------------------------------
ARG PB_VERSION=0.40.2 # Stage 1: Install frontend dependencies (Native execution on host architecture)
# ------------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps
WORKDIR /work
# ---------- Stage 1: build the TanStack Start app ---------- COPY app/package.json app/package-lock.json* ./
FROM node:${NODE_VERSION}-alpine AS build RUN --mount=type=cache,target=/root/.npm \
WORKDIR /app npm ci
COPY app/package.json app/package-lock.json ./ # ------------------------------------------------------------------------------
RUN npm ci --no-audit --no-fund # Stage 2: Build the TanStack Start frontend (Native execution on host architecture)
# ------------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder
WORKDIR /work
COPY --from=deps /work/node_modules ./node_modules
COPY app/ ./ COPY app/ ./
ENV NODE_ENV=production
RUN npm run build RUN npm run build
# ---------- Stage 2: runtime ---------- # ------------------------------------------------------------------------------
FROM node:${NODE_VERSION}-alpine AS runtime # Stage 3: Fetch PocketBase binary for target architecture (Native host downloader)
ARG PB_VERSION # ------------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader
ARG TARGETARCH ARG TARGETARCH
ARG PB_VERSION=0.40.2
ENV NODE_ENV=production \ RUN apk add --no-cache curl unzip ca-certificates \
PORT=3000 \ && case "${TARGETARCH}" in \
PB_URL=http://127.0.0.1:8090 amd64) PB_ARCH="amd64" ;; \
arm64) PB_ARCH="arm64" ;; \
arm) PB_ARCH="armv7" ;; \
*) echo "Unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/pb.zip "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \
&& unzip /tmp/pb.zip -d /tmp/pb \
&& chmod +x /tmp/pb/pocketbase \
&& rm -f /tmp/pb.zip
# ------------------------------------------------------------------------------
# Stage 4: Production runner (Target architecture)
# ------------------------------------------------------------------------------
FROM node:lts-trixie-slim AS runner
LABEL maintainer="Amritanshu <docker@tanshu.com>"
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
RUN apk add --no-cache curl unzip # Copy PocketBase binary and migrations
COPY --from=pb-downloader /tmp/pb/pocketbase /app/pocketbase
COPY pb/migrations /app/pb_migrations
RUN mkdir -p /app/pb_public
# Site server build output # Copy compiled frontend application
COPY --from=build /app/.output ./.output COPY --from=builder /work/.output /app/.output
# Content seed (shared with the frontend fallback) + process supervisor # Copy content seed + entrypoint script
COPY app/src/data/content.json ./scripts/content.json COPY app/src/data/content.json /app/scripts/content.json
COPY scripts/seed.mjs scripts/run-all.mjs ./scripts/ COPY scripts/seed.mjs /app/scripts/seed.mjs
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
# PocketBase schema migrations # Environment variables
COPY pb/migrations ./pb_migrations ENV NODE_ENV=production \
RUN mkdir -p pb_public PORT=3000 \
HOST=0.0.0.0 \
PB_DATA_DIR=/app/pb_data
# PocketBase binary (static Go binary, runs fine on Alpine) # Persistent data volume for PocketBase SQLite database and file storage
RUN case "${TARGETARCH:-amd64}" in \ VOLUME ["/app/pb_data"]
arm64) PB_ARCH=arm64 ;; \
*) PB_ARCH=amd64 ;; \
esac \
&& curl -fsSL -o /tmp/pb.zip \
"https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \
&& unzip -o /tmp/pb.zip pocketbase -d /app \
&& chmod +x /app/pocketbase \
&& rm /tmp/pb.zip \
&& mkdir -p /app/pb_data
# Runs as root so the Ansible-managed bind mount (/var/lib/greatbear/pb_data)
# is writable regardless of host-side ownership.
# Port 3000: TanStack Start Web Application
# Port 8090: PocketBase REST API & Admin UI
EXPOSE 3000 8090 EXPOSE 3000 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \ # Health check for both PocketBase and frontend server
CMD curl -fsS http://127.0.0.1:8090/api/health -o /dev/null || exit 1 HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8090/api/health > /dev/null && curl -fsS http://127.0.0.1:3000 > /dev/null || exit 1
CMD ["node", "scripts/run-all.mjs"] ENTRYPOINT ["/app/docker-entrypoint.sh"]
+28
View File
@@ -0,0 +1,28 @@
.PHONY: build-production
build-production: ## Build the production docker image and push to private registry
@docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag registry.tanshu.com/tanshu/greatbear:latest \
$(if $(filter-out latest,$(TAG)),--tag registry.tanshu.com/tanshu/greatbear:$(TAG)) \
--push \
--pull \
git@git.tanshu.com:tanshu/greatbear.git
.PHONY: build-check
build-check: ## Multi-arch build without push (compile check)
@docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag greatbear:test \
--pull \
--progress=plain \
git@git.tanshu.com:tanshu/greatbear.git
.PHONY: build-check-local
build-check-local: ## Build from the committed local tree (git archive) and load into docker
@git archive --format=tar HEAD | docker buildx build \
--platform linux/amd64 \
--tag greatbear:test \
--pull \
--progress=plain \
--load \
-
+26 -13
View File
@@ -9,7 +9,7 @@ Stitch "Industrial Hospitality" design.
| Backend | [PocketBase](https://pocketbase.io) v0.40 — CMS content, form intake, admin UI | | Backend | [PocketBase](https://pocketbase.io) v0.40 — CMS content, form intake, admin UI |
| Runtime | Single Docker image running both services | | Runtime | Single Docker image running both services |
| Proxy | Caddy (automatic HTTPS) on the target host | | Proxy | Caddy (automatic HTTPS) on the target host |
| Deploys | `deploy.sh` (build + push) → Ansible playbook (pull + run) | | Deploys | `deploy.sh [tag]` (Makefile buildx push) → Ansible playbook |
## Pages ## Pages
@@ -33,12 +33,13 @@ app/ TanStack Start application
public/images/ Design assets (downloaded from the Stitch export) public/images/ Design assets (downloaded from the Stitch export)
pb/migrations/ PocketBase schema (snapshot migration, auto-applied) pb/migrations/ PocketBase schema (snapshot migration, auto-applied)
scripts/ scripts/
run-all.mjs Container supervisor: PocketBase + site server
seed.mjs Idempotent content seeder (create-if-missing by uid) seed.mjs Idempotent content seeder (create-if-missing by uid)
create-collections.sh Dev helper used to build the schema migration create-collections.sh Dev helper used to build the schema migration
ansible/ Deployment playbook (roles: network, greatbear, caddy) ansible/ Deployment playbook (roles: network, greatbear, caddy)
Dockerfile Multi-stage image build Makefile Multi-arch buildx targets (build-production / build-check)
deploy.sh Build & push image to the private registry Dockerfile Multi-stage image build (deps → builder → pb-downloader → runner)
docker-entrypoint.sh Container supervisor: PocketBase + site server
deploy.sh Build & push image, then run the Ansible deploy
``` ```
## Local development ## Local development
@@ -86,16 +87,25 @@ docker run --rm -p 3000:3000 -p 8090:8090 \
## Deploying ## Deploying
### 1. Build and push the image The image is always built from the **committed state of the git remote** — commit and push
before deploying. Registry credentials: `docker login registry.tanshu.com` (once per machine).
```bash ```bash
cp .env.example .env # then fill in registry credentials ./deploy.sh # build + push :latest, then deploy that tag via Ansible
./deploy.sh ./deploy.sh v1.2.3 # same, with an explicit version tag
``` ```
Tags pushed: `registry.tanshu.com/tanshu/greatbear:latest` and `:<git-sha>`. Or run the pieces yourself:
### 2. Deploy with Ansible ```bash
make build-production TAG=v1.2.3 # multi-arch (amd64+arm64) buildx build & push
make build-check # multi-arch compile check (no push)
make build-check-local # build the committed local tree (git archive) and load it
cd ansible && ansible-playbook playbook.yml -e "tag=v1.2.3"
```
### Ansible deployment
Requirements on the controller: `ansible` + `community.docker` collection (see Requirements on the controller: `ansible` + `community.docker` collection (see
`ansible/requirements.yml`). Requirements on the host: Docker with a Caddy **container** named `ansible/requirements.yml`). Requirements on the host: Docker with a Caddy **container** named
@@ -107,10 +117,11 @@ ansible-galaxy install -r requirements.yml
ansible-playbook playbook.yml # targets inventory host `monoco` — edit playbook.yml if needed ansible-playbook playbook.yml # targets inventory host `monoco` — edit playbook.yml if needed
``` ```
All deployment variables live in `ansible/vars/default.yml`. The playbook: All deployment variables live in `ansible/vars/default.yml` (registry, tag, domains,
PocketBase admin credentials). The playbook:
1. Ensures the `greatbear_net` Docker network exists with the Caddy container attached. 1. Ensures the `greatbear_net` Docker network exists with the Caddy container attached.
2. Pulls `registry.tanshu.com/tanshu/greatbear:latest`, uploads `/var/lib/greatbear/.env`, 2. Pulls `registry.tanshu.com/tanshu/greatbear:{tag}`, uploads `/var/lib/greatbear/.env`,
and (re)creates the `greatbear` container on that network with and (re)creates the `greatbear` container on that network with
`/var/lib/greatbear/pb_data` bind-mounted to `/app/pb_data`, then waits for its healthcheck. `/var/lib/greatbear/pb_data` bind-mounted to `/app/pb_data`, then waits for its healthcheck.
3. Inserts a managed snippet into the shared Caddyfile and reloads Caddy via 3. Inserts a managed snippet into the shared Caddyfile and reloads Caddy via
@@ -129,7 +140,8 @@ automatically on first request.
- URL: `https://admin.greatbear.in/_/` - URL: `https://admin.greatbear.in/_/`
- First container start bootstraps the superuser from `PB_SUPERUSER_EMAIL` / - First container start bootstraps the superuser from `PB_SUPERUSER_EMAIL` /
`PB_SUPERUSER_PASSWORD` (set in `ansible/group_vars/greatbear/vars.yml`, idempotent). `PB_SUPERUSER_PASSWORD` (templated from `pb_admin_email` / `pb_admin_password` in
`ansible/vars/default.yml`, idempotent).
Content collections: `brews`, `menu_categories`, `menu_items`, `experiences`, `testimonials`, Content collections: `brews`, `menu_categories`, `menu_items`, `experiences`, `testimonials`,
`stats`, `pairings`, `settings`. Reservation/contact submissions land in `reservations` `stats`, `pairings`, `settings`. Reservation/contact submissions land in `reservations`
@@ -143,7 +155,8 @@ are never overwritten by redeploys. Schema changes belong in `pb/migrations/`.
| Variable | Default | Purpose | | Variable | Default | Purpose |
| ----------------------- | ------------------------ | -------------------------------------- | | ----------------------- | ------------------------ | -------------------------------------- |
| `PORT` | `3000` | Site server port | | `PORT` | `3000` | Site server port |
| `HOST` | `0.0.0.0` | Site server bind address |
| `PB_URL` | `http://127.0.0.1:8090` | PocketBase URL used by the site server | | `PB_URL` | `http://127.0.0.1:8090` | PocketBase URL used by the site server |
| `PB_DATA_DIR` | `/data` | PocketBase data directory (volume) | | `PB_DATA_DIR` | `/app/pb_data` | PocketBase data directory (volume) |
| `PB_SUPERUSER_EMAIL` | — | Initial admin (bootstrapped at start) | | `PB_SUPERUSER_EMAIL` | — | Initial admin (bootstrapped at start) |
| `PB_SUPERUSER_PASSWORD` | — | Initial admin password | | `PB_SUPERUSER_PASSWORD` | — | Initial admin password |
+13 -34
View File
@@ -1,40 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#
# Build the Great Bear image and push it to the private registry.
#
# Reads registry credentials from the environment or from ./.env
# (REGISTRY_USER, REGISTRY_PASSWORD). Optional overrides: REGISTRY,
# IMAGE_NAME, PLATFORM.
#
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")"
# shellcheck disable=SC1091 parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" || exit ; pwd -P )
[ -f .env ] && set -a && . ./.env && set +a cd "$parent_path" || exit
REGISTRY="${REGISTRY:-registry.tanshu.com}" echo "========================================================"
IMAGE_NAME="${IMAGE_NAME:-tanshu/greatbear}" echo " Building & Uploading Docker Image"
REGISTRY_USER="${REGISTRY_USER:?REGISTRY_USER not set (define it in .env or the environment)}" echo "========================================================"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:?REGISTRY_PASSWORD not set (define it in .env or the environment)}" current_version="${1:-latest}"
PLATFORM="${PLATFORM:-linux/amd64}" make build-production TAG="$current_version"
IMAGE="$REGISTRY/$IMAGE_NAME"
VERSION="$(git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d%H%M%S)"
echo "==> Building $IMAGE:$VERSION (and :latest) for $PLATFORM" echo ""
docker build --platform "$PLATFORM" \ echo "========================================================"
-t "$IMAGE:latest" \ echo " Executing Ansible Deployment"
-t "$IMAGE:$VERSION" \ echo "========================================================"
. cd "$parent_path/ansible" || exit
ansible-playbook playbook.yml -e "tag=$current_version"
echo "==> Logging in to $REGISTRY"
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
echo "==> Pushing $IMAGE:latest"
docker push "$IMAGE:latest"
echo "==> Pushing $IMAGE:$VERSION"
docker push "$IMAGE:$VERSION"
echo
echo "Done. Deploy with:"
echo " cd ansible && ansible-playbook playbook.yml"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
#
# Container entrypoint: runs PocketBase and the TanStack Start site server
# side by side.
# 1. starts PocketBase (data in $PB_DATA_DIR, migrations from ./pb_migrations)
# 2. waits for its health endpoint
# 3. bootstraps the superuser account (idempotent upsert)
# 4. seeds initial content (idempotent, create-if-missing)
# 5. starts the site server
#
# If either process dies the container exits non-zero so Docker restarts it.
set -e
PB_DATA="${PB_DATA_DIR:-/app/pb_data}"
PB_HTTP="${PB_HTTP:-0.0.0.0:8090}"
pb_pid=""
site_pid=""
cleanup() {
[ -n "$site_pid" ] && kill "$site_pid" 2>/dev/null || true
[ -n "$pb_pid" ] && kill "$pb_pid" 2>/dev/null || true
wait 2>/dev/null || true
}
trap cleanup INT TERM
echo "[entrypoint] starting PocketBase…"
./pocketbase serve \
--http="$PB_HTTP" \
--dir="$PB_DATA" \
--migrationsDir=./pb_migrations \
--publicDir=./pb_public &
pb_pid=$!
echo "[entrypoint] waiting for PocketBase health…"
for _ in $(seq 1 120); do
if curl -fsS http://127.0.0.1:8090/api/health > /dev/null 2>&1; then
break
fi
sleep 0.5
done
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
./pocketbase superuser upsert "$PB_SUPERUSER_EMAIL" "$PB_SUPERUSER_PASSWORD" \
--dir="$PB_DATA" --migrationsDir=./pb_migrations || true
echo "[entrypoint] superuser ensured"
node scripts/seed.mjs || echo "[entrypoint] seed failed (continuing)"
else
echo "[entrypoint] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping superuser + seed"
fi
echo "[entrypoint] starting site server on :${PORT}"
node .output/server/index.mjs &
site_pid=$!
set +e
wait -n "$pb_pid" "$site_pid"
exit_code=$?
echo "[entrypoint] a child process exited with code $exit_code — shutting down"
cleanup
exit "$exit_code"
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env node
/**
* Container entrypoint supervisor.
*
* Runs PocketBase and the TanStack Start node server side by side:
* 1. starts PocketBase (data in /data, migrations from ./pb_migrations)
* 2. waits for its health endpoint
* 3. bootstraps the superuser account (idempotent upsert)
* 4. seeds initial content (idempotent, create-if-missing)
* 5. starts the site server on PORT (default 3000)
*
* If either process dies the container exits non-zero so Docker restarts it.
*/
import { spawn, spawnSync } from 'node:child_process'
const PORT = process.env.PORT ?? '3000'
const PB_HTTP = process.env.PB_HTTP ?? '0.0.0.0:8090'
// PB_DATA_DIR is the canonical variable (set in the deploy .env); PB_DATA kept
// as a legacy alias, /data as the image default.
const PB_DATA = process.env.PB_DATA_DIR ?? process.env.PB_DATA ?? '/data'
let stopping = false
const children = []
function start(name, command, args, extraEnv = {}) {
const child = spawn(command, args, {
stdio: 'inherit',
env: { ...process.env, ...extraEnv },
})
child.name = name
children.push(child)
child.on('exit', (code) => {
if (stopping) return
console.error(`[supervisor] ${name} exited with code ${code} — shutting down`)
shutdown(code ?? 1)
})
return child
}
function run(name, command, args, extraEnv = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: { ...process.env, ...extraEnv },
})
if (result.status !== 0) {
console.warn(`[supervisor] ${name} finished with status ${result.status}`)
}
return result.status ?? 0
}
function shutdown(exitCode = 0) {
if (stopping) return
stopping = true
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM')
}
setTimeout(() => process.exit(exitCode), 2000)
}
process.on('SIGINT', () => shutdown(0))
process.on('SIGTERM', () => shutdown(0))
async function waitForHealth(url, attempts = 120) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url)
if (res.ok) return
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
throw new Error(`health check at ${url} timed out`)
}
async function main() {
console.log('[supervisor] starting PocketBase…')
start('pocketbase', './pocketbase', [
'serve',
`--http=${PB_HTTP}`,
`--dir=${PB_DATA}`,
'--migrationsDir=./pb_migrations',
'--publicDir=./pb_public',
])
await waitForHealth('http://127.0.0.1:8090/api/health')
console.log('[supervisor] PocketBase is healthy')
if (process.env.PB_SUPERUSER_EMAIL && process.env.PB_SUPERUSER_PASSWORD) {
run('superuser upsert', './pocketbase', [
'superuser',
'upsert',
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD,
`--dir=${PB_DATA}`,
'--migrationsDir=./pb_migrations',
])
console.log('[supervisor] superuser ensured')
run('seed', 'node', ['scripts/seed.mjs'])
} else {
console.warn('[supervisor] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping superuser + seed')
}
console.log(`[supervisor] starting site server on :${PORT}`)
start('site', 'node', ['.output/server/index.mjs'], {
PORT,
PB_URL: process.env.PB_URL ?? 'http://127.0.0.1:8090',
NODE_ENV: 'production',
})
}
main().catch((error) => {
console.error(`[supervisor] fatal: ${error.message}`)
shutdown(1)
})