diff --git a/.dockerignore b/.dockerignore
index 60774fc..52182e9 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,7 +4,8 @@ node_modules
*.md
.env
.env.*
-deployment/.env
+deployment/compose/.env
+deployment/**/.env
dist
.astro
__pycache__
diff --git a/.gitignore b/.gitignore
index 47bf39e..9f5fdc4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,7 +23,8 @@ htmlcov/
.env
.env.*
!.env.example
-deployment/.env
+deployment/compose/.env
+deployment/**/.env
# Logs
*.log
diff --git a/README.md b/README.md
index b88e8ba..f628d51 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Ordinary public browsing does **not** require the Backend at runtime. Administra
- **Frontend / Administration FE**: Astro 5, React 19 (Administration FE only), TypeScript
- **Backend**: FastAPI, Python 3.12, PostgreSQL 16
- **Object storage**: S3-compatible (MinIO locally)
-- **Deployment**: Docker Compose in `deployment/`
+- **Deployment**: Docker Compose + k3s under `deployment/`
## Repository layout
@@ -25,7 +25,9 @@ apps/
├── Frontend/
├── Administration-FE/
└── Backend/
-deployment/ # Docker Compose, environment config, gateway
+deployment/
+├── compose/ # Docker Compose, .env, Caddy gateway
+└── k8s/ # Kustomize manifests for Flycatch k3s
specs/001-website-foundation/ # Feature spec, plan, contracts, quickstart
specs/002-auth-rbac/ # JWT auth + RBAC spec, plan, contracts, quickstart
docs/ # Conventions and onboarding (implementation phase)
@@ -57,25 +59,25 @@ Compose does **not** create staff accounts or apply migrations. There is no defa
1. Copy environment config and set secrets (`JWT_SECRET` and the other `change-me` values):
```bash
- cp deployment/.env.example deployment/.env
+ cp deployment/compose/.env.example deployment/compose/.env
```
- Do not commit `deployment/.env`. Variable names are documented in `deployment/.env.example`.
+ Do not commit `deployment/compose/.env`. Variable names are documented in `deployment/compose/.env.example`.
2. Build and start all services from the repository root:
```bash
- docker compose -f deployment/docker-compose.yml up -d --build
+ docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env up -d --build
```
- From `deployment/` you can use `docker compose up -d --build` instead.
+ From `deployment/compose/` you can use `docker compose up -d --build` instead.
3. After Postgres and MinIO are healthy, migrate, seed, and bootstrap two staff users:
```bash
- docker compose -f deployment/docker-compose.yml exec backend alembic upgrade head
- docker compose -f deployment/docker-compose.yml exec backend flycatch-seed-records
- docker compose -f deployment/docker-compose.yml exec backend flycatch-bootstrap \
+ docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env exec backend alembic upgrade head
+ docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env exec backend flycatch-seed-records
+ docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env exec backend flycatch-bootstrap \
--user-1-email admin1@example.com \
--user-2-email admin2@example.com \
--user-2-role editor
@@ -90,10 +92,10 @@ Compose does **not** create staff accounts or apply migrations. There is no defa
5. Rebuild app images after Frontend or Administration FE changes:
```bash
- docker compose -f deployment/docker-compose.yml up -d --build
+ docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env up -d --build
```
- Stop the stack with `docker compose -f deployment/docker-compose.yml down`. Add `-v` only if you intend to wipe Postgres and MinIO volumes.
+ Stop the stack with `docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env down`. Add `-v` only if you intend to wipe Postgres and MinIO volumes.
Gateway (default `http://localhost:8080`, `GATEWAY_PORT` in `.env`):
@@ -107,9 +109,10 @@ Gateway (default `http://localhost:8080`, `GATEWAY_PORT` in `.env`):
| File | Purpose |
| --- | --- |
-| `deployment/docker-compose.yml` | All foundation services |
-| `deployment/.env.example` | Shared environment configuration |
-| `deployment/Caddyfile` | Path-based gateway routing |
+| `deployment/compose/docker-compose.yml` | All foundation services |
+| `deployment/compose/.env.example` | Shared environment configuration |
+| `deployment/k8s/base/Caddyfile` | Path-based gateway routing (compose + k8s) |
+| `deployment/k8s/` | Kubernetes manifests for the Flycatch k3s cluster |
## Local Development (Running Individually)
diff --git a/apps/Administration-FE/Dockerfile b/apps/Administration-FE/Dockerfile
index 2c20111..1d5a09a 100644
--- a/apps/Administration-FE/Dockerfile
+++ b/apps/Administration-FE/Dockerfile
@@ -5,14 +5,15 @@ RUN npm ci
COPY . .
COPY --from=specs . /specs
ENV CONTRACTS_DIR=/specs/002-auth-rbac/contracts
-ENV PUBLIC_ORIGIN=http://localhost:8080
+ARG PUBLIC_ORIGIN=http://localhost:8080
+ARG PUBLIC_ENVIRONMENT=development
+ENV PUBLIC_ORIGIN=$PUBLIC_ORIGIN
+ENV PUBLIC_ENVIRONMENT=$PUBLIC_ENVIRONMENT
RUN npm run generate:client && npm run build
FROM node:22-alpine
WORKDIR /app
-COPY package.json package-lock.json ./
-RUN npm ci --omit=dev
+RUN npm install -g serve@14
COPY --from=build /app/dist ./dist
-COPY --from=build /app/package.json ./
EXPOSE 4173
-CMD ["npm", "run", "preview"]
+CMD ["serve", "dist", "-l", "tcp://0.0.0.0:4173", "--no-clipboard", "--no-port-switching"]
diff --git a/apps/Administration-FE/astro.config.mjs b/apps/Administration-FE/astro.config.mjs
index 48c973b..4877003 100644
--- a/apps/Administration-FE/astro.config.mjs
+++ b/apps/Administration-FE/astro.config.mjs
@@ -7,6 +7,9 @@ export default defineConfig({
trailingSlash: 'always',
integrations: [react()],
vite: {
+ preview: {
+ allowedHosts: true,
+ },
server: {
proxy: {
'/api': {
diff --git a/apps/Administration-FE/package.json b/apps/Administration-FE/package.json
index 5d628b0..4117ef6 100644
--- a/apps/Administration-FE/package.json
+++ b/apps/Administration-FE/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "astro dev --host --port 4173",
"build": "astro build",
- "preview": "astro preview --host 0.0.0.0 --port 4173",
+ "preview": "astro preview --host 0.0.0.0 --port 4173 --allowed-hosts",
"check": "astro check",
"generate:client": "node scripts/generate-client.mjs",
"check:contracts": "node scripts/check-contract-drift.mjs",
diff --git a/apps/Backend/src/flycatch_api/main.py b/apps/Backend/src/flycatch_api/main.py
index 1606e02..39b272e 100644
--- a/apps/Backend/src/flycatch_api/main.py
+++ b/apps/Backend/src/flycatch_api/main.py
@@ -3,6 +3,7 @@
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError
+from flycatch_api.config import settings
from flycatch_api.api import (
admin_ai_services,
admin_auth,
@@ -74,7 +75,10 @@ async def security_headers(request: Request, call_next):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
- if request.url.path.startswith("/admin") or request.url.path.startswith("/api/v1/admin"):
+ is_admin_path = request.url.path.startswith("/admin") or request.url.path.startswith(
+ "/api/v1/admin"
+ )
+ if settings.environment != "production" or is_admin_path:
response.headers["X-Robots-Tag"] = "noindex, nofollow"
return response
diff --git a/apps/Frontend/Dockerfile b/apps/Frontend/Dockerfile
index 3b1f83c..6c9b736 100644
--- a/apps/Frontend/Dockerfile
+++ b/apps/Frontend/Dockerfile
@@ -3,13 +3,15 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
+ARG PUBLIC_ORIGIN=http://localhost:8080
+ARG PUBLIC_ENVIRONMENT=development
+ENV PUBLIC_ORIGIN=$PUBLIC_ORIGIN
+ENV PUBLIC_ENVIRONMENT=$PUBLIC_ENVIRONMENT
RUN npm run build
FROM node:22-alpine
WORKDIR /app
-COPY package.json package-lock.json ./
-RUN npm ci --omit=dev
+RUN npm install -g serve@14
COPY --from=build /app/dist ./dist
-COPY --from=build /app/package.json ./
EXPOSE 4321
-CMD ["npm", "run", "preview"]
+CMD ["serve", "dist", "-l", "tcp://0.0.0.0:4321", "--no-clipboard", "--no-port-switching"]
diff --git a/apps/Frontend/astro.config.mjs b/apps/Frontend/astro.config.mjs
index b39b15d..b97c8c8 100644
--- a/apps/Frontend/astro.config.mjs
+++ b/apps/Frontend/astro.config.mjs
@@ -13,6 +13,9 @@ export default defineConfig({
inlineStylesheets: 'always',
},
vite: {
+ preview: {
+ allowedHosts: true,
+ },
build: {
rollupOptions: {
output: {
diff --git a/apps/Frontend/package.json b/apps/Frontend/package.json
index d36765f..41ca8e7 100644
--- a/apps/Frontend/package.json
+++ b/apps/Frontend/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "astro dev --host",
"build": "node scripts/ensure-published-snapshot.mjs && astro build",
- "preview": "astro preview --host 0.0.0.0 --port 4321",
+ "preview": "astro preview --host 0.0.0.0 --port 4321 --allowed-hosts",
"check": "node scripts/ensure-published-snapshot.mjs && astro check",
"generate:types": "node scripts/generate-types.mjs",
"check:contracts": "node scripts/check-contract-drift.mjs",
diff --git a/apps/Frontend/public/robots.txt b/apps/Frontend/public/robots.txt
deleted file mode 100644
index 9937433..0000000
--- a/apps/Frontend/public/robots.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-User-agent: *
-Allow: /
-
-Disallow: /admin
-Disallow: /api
-
-Sitemap: /sitemap-index.xml
diff --git a/apps/Frontend/src/layouts/BaseLayout.astro b/apps/Frontend/src/layouts/BaseLayout.astro
index 6415cfd..80345be 100644
--- a/apps/Frontend/src/layouts/BaseLayout.astro
+++ b/apps/Frontend/src/layouts/BaseLayout.astro
@@ -13,6 +13,8 @@ interface Props {
}
const { title, metadata, structuredData = [], lang = 'en', dir = 'ltr' } = Astro.props;
+const isProduction = (import.meta.env.PUBLIC_ENVIRONMENT || 'development') === 'production';
+const shouldNoindex = !metadata.indexable || !isProduction;
---
@@ -22,7 +24,7 @@ const { title, metadata, structuredData = [], lang = 'en', dir = 'ltr' } = Astro
{title}
- {!metadata.indexable && }
+ {shouldNoindex && }
diff --git a/apps/Frontend/src/pages/robots.txt.ts b/apps/Frontend/src/pages/robots.txt.ts
new file mode 100644
index 0000000..b69a22e
--- /dev/null
+++ b/apps/Frontend/src/pages/robots.txt.ts
@@ -0,0 +1,24 @@
+import type { APIRoute } from 'astro';
+
+const isProduction = (import.meta.env.PUBLIC_ENVIRONMENT || 'development') === 'production';
+
+export const GET: APIRoute = () => {
+ const body = isProduction
+ ? [
+ 'User-agent: *',
+ 'Allow: /',
+ '',
+ 'Disallow: /admin',
+ 'Disallow: /api',
+ '',
+ 'Sitemap: /sitemap-index.xml',
+ '',
+ ].join('\n')
+ : ['User-agent: *', 'Disallow: /', ''].join('\n');
+
+ return new Response(body, {
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ },
+ });
+};
diff --git a/deployment/Caddyfile b/deployment/Caddyfile
deleted file mode 100644
index 6c525ac..0000000
--- a/deployment/Caddyfile
+++ /dev/null
@@ -1,27 +0,0 @@
-# Path-based gateway — single origin for Frontend, Administration FE, and Backend
-
-:{$GATEWAY_PORT:8080} {
- # Keep /admin on the Administration FE (never the public site)
- redir /admin /admin/ 308
-
- # Backend API
- handle /api/* {
- reverse_proxy backend:8000
- }
-
- # Administration FE — astro preview serves built assets at /_astro, not /admin/_astro
- handle /admin/_astro/* {
- uri strip_prefix /admin
- reverse_proxy administration-fe:4173
- }
-
- # Administration FE
- handle /admin* {
- reverse_proxy administration-fe:4173
- }
-
- # Public Frontend (static)
- handle {
- reverse_proxy frontend:4321
- }
-}
diff --git a/deployment/README.md b/deployment/README.md
index a8dbc8d..1b81a9d 100644
--- a/deployment/README.md
+++ b/deployment/README.md
@@ -1,41 +1,13 @@
# Deployment
-Deployment-specific files for Docker Compose and environment configuration.
+How this app runs locally and on the cluster. Everything lives under this folder.
-**Project overview, stack, and full setup instructions:** see [README.md](../README.md) at the repository root.
-
-## Files in this directory
-
-| File | Purpose |
+| Path | Purpose |
| --- | --- |
-| `docker-compose.yml` | Frontend, Administration FE, Backend, PostgreSQL, MinIO, gateway |
-| `.env.example` | Shared environment variables — copy to `.env` |
-| `Caddyfile` | Gateway routing: `/`, `/admin`, `/api` |
-
-## Quick start
-
-From this directory:
-
-```bash
-cp .env.example .env
-# Set JWT_SECRET and other change-me values
-
-docker compose up -d --build
-```
-
-From the repository root: `docker compose -f deployment/docker-compose.yml up -d --build`.
-
-Compose does not provision staff. After services are healthy:
-
-```bash
-docker compose exec backend alembic upgrade head
-docker compose exec backend flycatch-seed-records
-docker compose exec backend flycatch-bootstrap \
- --user-1-email admin1@example.com \
- --user-2-email admin2@example.com \
- --user-2-role editor
-```
+| [compose/](compose/) | Docker Compose stack (local / preview): Postgres, MinIO, apps, Caddy gateway |
+| [k8s/](k8s/) | Kubernetes manifests (Kustomize) for the Flycatch k3s cluster |
-There is no default password. Bootstrap prompts for two passwords (min 12 characters). Sign in at `http://localhost:8080/admin`. Full startup notes: [README.md](../README.md#quick-start-docker-compose) and [docs/onboarding.md](../docs/onboarding.md).
+Shared gateway routing for both compose and k8s: [k8s/base/Caddyfile](k8s/base/Caddyfile)
+(Compose mounts this file; k8s loads it via ConfigMap).
-Validation scenarios: [quickstart.md](../specs/001-website-foundation/quickstart.md).
+**Project overview and day-to-day setup:** [README.md](../README.md).
diff --git a/deployment/.env.example b/deployment/compose/.env.example
similarity index 81%
rename from deployment/.env.example
rename to deployment/compose/.env.example
index 90fbad6..6b83e53 100644
--- a/deployment/.env.example
+++ b/deployment/compose/.env.example
@@ -4,6 +4,11 @@
PUBLIC_ORIGIN=http://localhost:8080
GATEWAY_PORT=8080
+# Environment: development | production
+# Non-production disables SEO indexing (robots.txt Disallow, noindex meta, X-Robots-Tag).
+PUBLIC_ENVIRONMENT=development
+ENVIRONMENT=development
+
# PostgreSQL
POSTGRES_DB=flycatch
POSTGRES_USER=flycatch
diff --git a/deployment/compose/README.md b/deployment/compose/README.md
new file mode 100644
index 0000000..9e19db4
--- /dev/null
+++ b/deployment/compose/README.md
@@ -0,0 +1,49 @@
+# Docker Compose
+
+Local and preview stack: Frontend, Administration FE, Backend, PostgreSQL, MinIO, and the Caddy gateway.
+
+**Project overview:** see [README.md](../../README.md) at the repository root.
+**k3s / cluster deploy:** see [../k8s/README.md](../k8s/README.md).
+
+## Files in this directory
+
+| File | Purpose |
+| --- | --- |
+| `docker-compose.yml` | Frontend, Administration FE, Backend, PostgreSQL, MinIO, gateway |
+| `.env.example` | Shared environment variables — copy to `.env` |
+
+Gateway routing is shared with k8s: [../k8s/base/Caddyfile](../k8s/base/Caddyfile).
+
+## Quick start
+
+From this directory:
+
+```bash
+cp .env.example .env
+# Set JWT_SECRET and other change-me values
+
+docker compose up -d --build
+```
+
+From the repository root:
+
+```bash
+docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env up -d --build
+```
+
+Compose does not provision staff. After services are healthy:
+
+```bash
+docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env exec backend alembic upgrade head
+docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env exec backend flycatch-seed-records
+docker compose -f deployment/compose/docker-compose.yml --env-file deployment/compose/.env exec backend flycatch-bootstrap \
+ --user-1-email admin1@example.com \
+ --user-2-email admin2@example.com \
+ --user-2-role editor
+```
+
+There is no default password. Bootstrap prompts for two passwords (min 12 characters). Sign in at `http://localhost:8080/admin`. Full startup notes: [README.md](../../README.md#quick-start-docker-compose) and [docs/onboarding.md](../../docs/onboarding.md).
+
+`PUBLIC_ENVIRONMENT` / `ENVIRONMENT` default to `development` so pages are not SEO-indexed.
+
+Validation scenarios: [quickstart.md](../../specs/001-website-foundation/quickstart.md).
diff --git a/deployment/docker-compose.yml b/deployment/compose/docker-compose.yml
similarity index 79%
rename from deployment/docker-compose.yml
rename to deployment/compose/docker-compose.yml
index fc53110..246fb85 100644
--- a/deployment/docker-compose.yml
+++ b/deployment/compose/docker-compose.yml
@@ -32,7 +32,7 @@ services:
backend:
build:
- context: ../apps/Backend
+ context: ../../apps/Backend
env_file: .env
environment:
DATABASE_URL: ${DATABASE_URL}
@@ -46,6 +46,7 @@ services:
JWT_ACCESS_MINUTES: ${JWT_ACCESS_MINUTES:-15}
BUILD_EXPORT_TOKEN: ${BUILD_EXPORT_TOKEN}
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN}
+ ENVIRONMENT: ${ENVIRONMENT:-development}
depends_on:
postgres:
condition: service_healthy
@@ -54,15 +55,21 @@ services:
frontend:
build:
- context: ../apps/Frontend
+ context: ../../apps/Frontend
+ args:
+ PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost:8080}
+ PUBLIC_ENVIRONMENT: ${PUBLIC_ENVIRONMENT:-development}
depends_on:
- backend
administration-fe:
build:
- context: ../apps/Administration-FE
+ context: ../../apps/Administration-FE
additional_contexts:
- specs: ../specs
+ specs: ../../specs
+ args:
+ PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost:8080}
+ PUBLIC_ENVIRONMENT: ${PUBLIC_ENVIRONMENT:-development}
environment:
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN}
depends_on:
@@ -75,7 +82,7 @@ services:
environment:
GATEWAY_PORT: "8080"
volumes:
- - ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - ../k8s/base/Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- frontend
- administration-fe
diff --git a/deployment/k8s/README.md b/deployment/k8s/README.md
new file mode 100644
index 0000000..988bac4
--- /dev/null
+++ b/deployment/k8s/README.md
@@ -0,0 +1,249 @@
+# Kubernetes (dev) — Harbor + Argo CD
+
+Secrets and credentials must never be committed. Bootstrap against the Flycatch k3s
+cluster using this file as the single source of truth.
+
+Compose / local setup lives next door: [../compose/README.md](../compose/README.md).
+The Caddy gateway config is shared at [base/Caddyfile](base/Caddyfile) (Compose mounts the same file).
+
+## Layout
+
+```
+deployment/k8s/
+ base/ # Namespace, Deployments, Services, ConfigMap, Caddyfile ConfigMap
+ overlays/dev/ # Ingress (TLS), noindex Middleware, image tags, replica counts
+ scripts/deploy-dev.sh # Build/push Harbor images + bump overlay tags
+```
+
+The Argo CD Application is owned by the platform app-of-apps in
+[flycatch/k3s-platform](https://github.com/flycatch/k3s-platform):
+
+`infrastructure/flycatch-website/application.yaml`
+
+Do **not** `kubectl apply` an Application from this repo — that would duplicate the
+app and use the wrong Argo project (`default` instead of `platform`).
+
+Ingress routes only to `gateway:8080`. Caddy path-splits `/`, `/admin`, and `/api`
+to the Frontend, Administration FE, and Backend Services (same names as Compose).
+
+**SEO:** this overlay is a non-production environment. App builds use
+`PUBLIC_ENVIRONMENT=development` / `ENVIRONMENT=development`, and Traefik Middleware
+`noindex` adds `X-Robots-Tag: noindex, nofollow` on every response.
+
+Hostname: `https://flycatch-website-dev.k3s.flycatchtech.in`
+
+## Prerequisites
+
+- kubectl context pointing at the Flycatch k3s cluster
+- Harbor project `flycatch-website` + robot with push (local script) and pull (cluster)
+- Shared Postgres in namespace `database` healthy (Bitnami; container name `postgresql`)
+- Shared MinIO in namespace `database` healthy (Service `minio.database.svc.cluster.local:9000`)
+- Traefik IngressClass and cert-manager ClusterIssuer `letsencrypt-production`
+- Cloudflare DNS access for `*.k3s.flycatchtech.in`
+- Local tools: `docker`, `kustomize`, `git`
+
+Preview manifests without applying:
+
+```bash
+kubectl kustomize deployment/k8s/overlays/dev
+```
+
+## 0. Argo CD access to the app repo
+
+If the app repo is private, Argo CD must be able to clone it, or the Application
+`flycatch-website-dev` stays `Unknown` with authentication errors.
+
+```bash
+kubectl -n argocd create secret generic repo-flycatch-website \
+ --from-literal=type=git \
+ --from-literal=url=https://github.com/flycatch/flycatch-website.git \
+ --from-literal=username=git \
+ --from-literal=password='' \
+ --dry-run=client -o yaml | kubectl label --local -f - \
+ argocd.argoproj.io/secret-type=repository -o yaml | kubectl apply -f -
+```
+
+The `url` must match the Application source exactly. Then hard-refresh:
+
+```bash
+kubectl -n argocd annotate application flycatch-website-dev \
+ argocd.argoproj.io/refresh=hard --overwrite
+```
+
+## 1. Namespace + Harbor pull secret
+
+```bash
+kubectl create namespace flycatch-website-dev --dry-run=client -o yaml | kubectl apply -f -
+
+kubectl -n flycatch-website-dev create secret docker-registry harbor-pull \
+ --docker-server=registry.k3s.flycatchtech.in \
+ --docker-username='robot$flycatch-website+githubbot' \
+ --docker-password='' \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+Create `harbor-pull` **before** workloads start, or pods stay in `ImagePullBackOff`.
+
+## 2. Postgres role and database (reuse shared cluster Postgres)
+
+Service is `postgres.database.svc.cluster.local`. Use container `postgresql`.
+Do **not** deploy a new Postgres pod for this app.
+
+First install:
+
+```bash
+kubectl -n database exec -it sts/postgres -c postgresql -- \
+ env PGPASSWORD="" \
+ psql -U postgres \
+ -c "CREATE ROLE flycatch_website LOGIN PASSWORD '';" \
+ -c "CREATE DATABASE flycatch_website OWNER flycatch_website;"
+```
+
+Reinstall (role/database already exist — `CREATE` will fail):
+
+```bash
+kubectl -n database exec -it sts/postgres -c postgresql -- \
+ env PGPASSWORD="" \
+ psql -U postgres \
+ -c "ALTER ROLE flycatch_website LOGIN PASSWORD '';" \
+ -c "ALTER DATABASE flycatch_website OWNER TO flycatch_website;"
+```
+
+Connection string used by the Backend:
+
+```text
+postgresql+psycopg://flycatch_website:@postgres.database.svc.cluster.local:5432/flycatch_website
+```
+
+Migrations run automatically on Backend container start (`alembic upgrade head`).
+
+## 3. MinIO bucket and credentials (reuse shared cluster MinIO)
+
+Service is `minio.database.svc.cluster.local:9000`. Create a dedicated bucket and
+least-privilege access key for this app (do not reuse MinIO root credentials in the
+app Secret if you can avoid it).
+
+Example with the MinIO client against a port-forward:
+
+```bash
+kubectl -n database port-forward svc/minio 9000:9000
+
+# In another shell, after mc alias set ...
+mc mb myminio/flycatch-website
+mc admin user add myminio flycatch-website '' ''
+# Attach a policy that allows read/write only on bucket flycatch-website
+```
+
+ConfigMap already points `S3_ENDPOINT` / `S3_BUCKET` at the shared service and
+`flycatch-website` bucket. Put the access key pair in the app Secret.
+
+## 4. App secrets
+
+Template: [overlays/dev/secret.example.yaml](overlays/dev/secret.example.yaml)
+(not applied by Kustomize).
+
+```bash
+kubectl -n flycatch-website-dev create secret generic flycatch-website-secrets \
+ --from-literal=DATABASE_URL='postgresql+psycopg://flycatch_website:@postgres.database.svc.cluster.local:5432/flycatch_website' \
+ --from-literal=S3_ACCESS_KEY='' \
+ --from-literal=S3_SECRET_KEY='' \
+ --from-literal=SESSION_SECRET='' \
+ --from-literal=CSRF_SECRET='' \
+ --from-literal=JWT_SECRET='' \
+ --from-literal=BUILD_EXPORT_TOKEN='' \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+## 5. DNS
+
+Create a Cloudflare A (or CNAME) record:
+
+```text
+flycatch-website-dev.k3s.flycatchtech.in →
+```
+
+(Same LB IP used by other `*.k3s.flycatchtech.in` apps.)
+
+## 6. Build, push, and bump image tags
+
+From a machine that can reach Harbor (LAN/VPN), with a clean git working tree:
+
+```bash
+export HARBOR_USERNAME='robot$flycatch-website+githubbot'
+export HARBOR_PASSWORD='...'
+./deployment/k8s/scripts/deploy-dev.sh
+```
+
+The script builds `linux/amd64` images, pushes `:SHA` and `:latest` to Harbor,
+updates `overlays/dev/kustomization.yaml` image tags, commits, and pushes so Argo CD
+can sync.
+
+Images:
+
+- `registry.k3s.flycatchtech.in/flycatch-website/backend`
+- `registry.k3s.flycatchtech.in/flycatch-website/frontend`
+- `registry.k3s.flycatchtech.in/flycatch-website/administration-fe`
+
+Frontend and Administration FE are built with
+`PUBLIC_ORIGIN=https://flycatch-website-dev.k3s.flycatchtech.in` and
+`PUBLIC_ENVIRONMENT=development`.
+
+## 7. Verify Argo CD sync
+
+```bash
+kubectl -n argocd get application flycatch-website-dev
+kubectl -n flycatch-website-dev get pods,ingress,certificate
+```
+
+## 8. One-time seed and staff bootstrap
+
+After the Backend pod is Ready:
+
+```bash
+kubectl -n flycatch-website-dev exec -it deploy/backend -- flycatch-seed-records
+kubectl -n flycatch-website-dev exec -it deploy/backend -- flycatch-bootstrap \
+ --user-1-email admin1@example.com \
+ --user-2-email admin2@example.com \
+ --user-2-role editor
+```
+
+Sign in at `https://flycatch-website-dev.k3s.flycatchtech.in/admin`.
+
+## 9. SEO / noindex checks
+
+```bash
+curl -sI https://flycatch-website-dev.k3s.flycatchtech.in/ | grep -i robots
+curl -s https://flycatch-website-dev.k3s.flycatchtech.in/robots.txt
+```
+
+Expect `X-Robots-Tag: noindex, nofollow` and `Disallow: /` in robots.txt.
+
+## 10. Security headers checks
+
+The shared Caddy gateway ([base/Caddyfile](base/Caddyfile)) sets HSTS, COOP,
+`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and
+`Permissions-Policy` on every response. CSP is route-scoped: strict on the
+public site, wider on `/admin*` (inline scripts for Astro islands, Google
+Fonts, and `blob:` media previews).
+
+```bash
+curl -sI https://flycatch-website-dev.k3s.flycatchtech.in/ \
+ | grep -iE 'content-security|strict-transport|cross-origin|x-frame|x-content'
+curl -sI https://flycatch-website-dev.k3s.flycatchtech.in/admin/ \
+ | grep -i content-security
+```
+
+Expect `Strict-Transport-Security`, `Cross-Origin-Opener-Policy: same-origin`,
+`X-Frame-Options: DENY`, and a `Content-Security-Policy` on both `/` and
+`/admin/`. The admin policy should include `'unsafe-inline'` in `script-src`
+and the Google Fonts origins. After deploy, load `/admin/` in a browser and
+confirm the console has no CSP violations.
+
+Local Compose (HTTP on `:8080`) returns the same headers; browsers ignore
+HSTS over non-HTTPS.
+
+## Rollback
+
+Revert the image-tag commit in `overlays/dev/kustomization.yaml` (or re-run
+`deploy-dev.sh` from an older commit) and let Argo CD sync. Secrets, DNS, and the
+shared Postgres/MinIO data are unchanged by that rollback.
diff --git a/deployment/k8s/base/Caddyfile b/deployment/k8s/base/Caddyfile
new file mode 100644
index 0000000..9bbb862
--- /dev/null
+++ b/deployment/k8s/base/Caddyfile
@@ -0,0 +1,47 @@
+# Shared gateway routing for Docker Compose and k8s.
+# Compose mounts this path; k8s generates a ConfigMap from it.
+# Path-based gateway — single origin for Frontend, Administration FE, and Backend
+
+(base_security) {
+ header {
+ # defer so these win over headers copied from upstream (e.g. Backend)
+ defer
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "DENY"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ Cross-Origin-Opener-Policy "same-origin"
+ Permissions-Policy "geolocation=(), camera=(), microphone=()"
+ }
+}
+
+:{$GATEWAY_PORT:8080} {
+ import base_security
+
+ # Keep /admin on the Administration FE (never the public site)
+ redir /admin /admin/ 308
+
+ # Backend API
+ handle /api/* {
+ reverse_proxy backend:8000
+ }
+
+ # Administration FE — astro preview serves built assets at /_astro, not /admin/_astro
+ handle /admin/_astro/* {
+ uri strip_prefix /admin
+ header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:"
+ reverse_proxy administration-fe:4173
+ }
+
+ # Administration FE
+ handle /admin* {
+ header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:"
+ reverse_proxy administration-fe:4173
+ }
+
+ # Public Frontend (static)
+ handle {
+ header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
+ reverse_proxy frontend:4321
+ }
+}
diff --git a/deployment/k8s/base/administration-fe-deployment.yaml b/deployment/k8s/base/administration-fe-deployment.yaml
new file mode 100644
index 0000000..c90e7b0
--- /dev/null
+++ b/deployment/k8s/base/administration-fe-deployment.yaml
@@ -0,0 +1,52 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: administration-fe
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: administration-fe
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: administration-fe
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: administration-fe
+ spec:
+ imagePullSecrets:
+ - name: harbor-pull
+ containers:
+ - name: administration-fe
+ image: registry.k3s.flycatchtech.in/flycatch-website/administration-fe:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 4173
+ protocol: TCP
+ readinessProbe:
+ httpGet:
+ path: /admin/
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ timeoutSeconds: 3
+ failureThreshold: 3
+ livenessProbe:
+ httpGet:
+ path: /admin/
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 20
+ timeoutSeconds: 3
+ failureThreshold: 3
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 256Mi
diff --git a/deployment/k8s/base/administration-fe-service.yaml b/deployment/k8s/base/administration-fe-service.yaml
new file mode 100644
index 0000000..9c8f243
--- /dev/null
+++ b/deployment/k8s/base/administration-fe-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: administration-fe
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: administration-fe
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: administration-fe
+ ports:
+ - name: http
+ port: 4173
+ targetPort: http
+ protocol: TCP
diff --git a/deployment/k8s/base/backend-deployment.yaml b/deployment/k8s/base/backend-deployment.yaml
new file mode 100644
index 0000000..539e904
--- /dev/null
+++ b/deployment/k8s/base/backend-deployment.yaml
@@ -0,0 +1,57 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: backend
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: backend
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: backend
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: backend
+ spec:
+ imagePullSecrets:
+ - name: harbor-pull
+ containers:
+ - name: backend
+ image: registry.k3s.flycatchtech.in/flycatch-website/backend:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 8000
+ protocol: TCP
+ envFrom:
+ - configMapRef:
+ name: flycatch-website-config
+ - secretRef:
+ name: flycatch-website-secrets
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 6
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 45
+ periodSeconds: 20
+ timeoutSeconds: 5
+ failureThreshold: 3
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: "1"
+ memory: 1Gi
diff --git a/deployment/k8s/base/backend-service.yaml b/deployment/k8s/base/backend-service.yaml
new file mode 100644
index 0000000..3b96eaf
--- /dev/null
+++ b/deployment/k8s/base/backend-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: backend
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: backend
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: backend
+ ports:
+ - name: http
+ port: 8000
+ targetPort: http
+ protocol: TCP
diff --git a/deployment/k8s/base/configmap.yaml b/deployment/k8s/base/configmap.yaml
new file mode 100644
index 0000000..91da033
--- /dev/null
+++ b/deployment/k8s/base/configmap.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: flycatch-website-config
+ labels:
+ app.kubernetes.io/name: flycatch-website
+data:
+ PUBLIC_ORIGIN: https://flycatch-website-dev.k3s.flycatchtech.in
+ PUBLIC_ENVIRONMENT: development
+ ENVIRONMENT: development
+ S3_ENDPOINT: http://minio.database.svc.cluster.local:9000
+ S3_BUCKET: flycatch-website
+ S3_REGION: us-east-1
+ S3_USE_SSL: "false"
+ JWT_ACCESS_MINUTES: "15"
+ GATEWAY_PORT: "8080"
diff --git a/deployment/k8s/base/frontend-deployment.yaml b/deployment/k8s/base/frontend-deployment.yaml
new file mode 100644
index 0000000..261b44d
--- /dev/null
+++ b/deployment/k8s/base/frontend-deployment.yaml
@@ -0,0 +1,52 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: frontend
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: frontend
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: frontend
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: frontend
+ spec:
+ imagePullSecrets:
+ - name: harbor-pull
+ containers:
+ - name: frontend
+ image: registry.k3s.flycatchtech.in/flycatch-website/frontend:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 4321
+ protocol: TCP
+ readinessProbe:
+ httpGet:
+ path: /
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ timeoutSeconds: 3
+ failureThreshold: 3
+ livenessProbe:
+ httpGet:
+ path: /
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 20
+ timeoutSeconds: 3
+ failureThreshold: 3
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 256Mi
diff --git a/deployment/k8s/base/frontend-service.yaml b/deployment/k8s/base/frontend-service.yaml
new file mode 100644
index 0000000..deeed4e
--- /dev/null
+++ b/deployment/k8s/base/frontend-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: frontend
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: frontend
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: frontend
+ ports:
+ - name: http
+ port: 4321
+ targetPort: http
+ protocol: TCP
diff --git a/deployment/k8s/base/gateway-deployment.yaml b/deployment/k8s/base/gateway-deployment.yaml
new file mode 100644
index 0000000..447b6f1
--- /dev/null
+++ b/deployment/k8s/base/gateway-deployment.yaml
@@ -0,0 +1,60 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gateway
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: gateway
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: gateway
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: gateway
+ spec:
+ containers:
+ - name: gateway
+ image: caddy:2-alpine
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ env:
+ - name: GATEWAY_PORT
+ value: "8080"
+ volumeMounts:
+ - name: caddyfile
+ mountPath: /etc/caddy/Caddyfile
+ subPath: Caddyfile
+ readOnly: true
+ readinessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 3
+ periodSeconds: 5
+ timeoutSeconds: 3
+ failureThreshold: 3
+ livenessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 20
+ timeoutSeconds: 3
+ failureThreshold: 3
+ resources:
+ requests:
+ cpu: 25m
+ memory: 32Mi
+ limits:
+ cpu: 200m
+ memory: 128Mi
+ volumes:
+ - name: caddyfile
+ configMap:
+ name: flycatch-website-caddyfile
diff --git a/deployment/k8s/base/gateway-service.yaml b/deployment/k8s/base/gateway-service.yaml
new file mode 100644
index 0000000..d2507d7
--- /dev/null
+++ b/deployment/k8s/base/gateway-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: gateway
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: gateway
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: flycatch-website
+ app.kubernetes.io/component: gateway
+ ports:
+ - name: http
+ port: 8080
+ targetPort: http
+ protocol: TCP
diff --git a/deployment/k8s/base/kustomization.yaml b/deployment/k8s/base/kustomization.yaml
new file mode 100644
index 0000000..d096a89
--- /dev/null
+++ b/deployment/k8s/base/kustomization.yaml
@@ -0,0 +1,24 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - namespace.yaml
+ - configmap.yaml
+ - backend-deployment.yaml
+ - backend-service.yaml
+ - frontend-deployment.yaml
+ - frontend-service.yaml
+ - administration-fe-deployment.yaml
+ - administration-fe-service.yaml
+ - gateway-deployment.yaml
+ - gateway-service.yaml
+
+configMapGenerator:
+ - name: flycatch-website-caddyfile
+ files:
+ - Caddyfile
+
+labels:
+ - pairs:
+ app.kubernetes.io/part-of: flycatch-website
+ includeSelectors: false
diff --git a/deployment/k8s/base/namespace.yaml b/deployment/k8s/base/namespace.yaml
new file mode 100644
index 0000000..e3e834f
--- /dev/null
+++ b/deployment/k8s/base/namespace.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: flycatch-website-dev
+ labels:
+ app.kubernetes.io/part-of: flycatch-website
diff --git a/deployment/k8s/overlays/dev/ingress.yaml b/deployment/k8s/overlays/dev/ingress.yaml
new file mode 100644
index 0000000..4eebbc9
--- /dev/null
+++ b/deployment/k8s/overlays/dev/ingress.yaml
@@ -0,0 +1,26 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: flycatch-website
+ labels:
+ app.kubernetes.io/name: flycatch-website
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-production
+ traefik.ingress.kubernetes.io/router.middlewares: flycatch-website-dev-noindex@kubernetescrd
+spec:
+ ingressClassName: traefik
+ tls:
+ - hosts:
+ - flycatch-website-dev.k3s.flycatchtech.in
+ secretName: flycatch-website-dev-tls
+ rules:
+ - host: flycatch-website-dev.k3s.flycatchtech.in
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: gateway
+ port:
+ number: 8080
diff --git a/deployment/k8s/overlays/dev/kustomization.yaml b/deployment/k8s/overlays/dev/kustomization.yaml
new file mode 100644
index 0000000..214db80
--- /dev/null
+++ b/deployment/k8s/overlays/dev/kustomization.yaml
@@ -0,0 +1,30 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: flycatch-website-dev
+
+resources:
+- ../../base
+- ingress.yaml
+- noindex-middleware.yaml
+
+images:
+- name: registry.k3s.flycatchtech.in/flycatch-website/administration-fe
+ newName: registry.k3s.flycatchtech.in/flycatch-website/administration-fe
+ newTag: 0b278535ef365379639be4f76ad8f4ac6fe84425
+- name: registry.k3s.flycatchtech.in/flycatch-website/backend
+ newName: registry.k3s.flycatchtech.in/flycatch-website/backend
+ newTag: 0b278535ef365379639be4f76ad8f4ac6fe84425
+- name: registry.k3s.flycatchtech.in/flycatch-website/frontend
+ newName: registry.k3s.flycatchtech.in/flycatch-website/frontend
+ newTag: 0b278535ef365379639be4f76ad8f4ac6fe84425
+
+replicas:
+- count: 1
+ name: backend
+- count: 1
+ name: frontend
+- count: 1
+ name: administration-fe
+- count: 1
+ name: gateway
diff --git a/deployment/k8s/overlays/dev/noindex-middleware.yaml b/deployment/k8s/overlays/dev/noindex-middleware.yaml
new file mode 100644
index 0000000..7fdaa08
--- /dev/null
+++ b/deployment/k8s/overlays/dev/noindex-middleware.yaml
@@ -0,0 +1,10 @@
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: noindex
+ labels:
+ app.kubernetes.io/name: flycatch-website
+spec:
+ headers:
+ customResponseHeaders:
+ X-Robots-Tag: "noindex, nofollow"
diff --git a/deployment/k8s/overlays/dev/secret.example.yaml b/deployment/k8s/overlays/dev/secret.example.yaml
new file mode 100644
index 0000000..30831d0
--- /dev/null
+++ b/deployment/k8s/overlays/dev/secret.example.yaml
@@ -0,0 +1,25 @@
+# Opaque Secret — create in-cluster; do NOT commit real values.
+# Example:
+# kubectl -n flycatch-website-dev create secret generic flycatch-website-secrets \
+# --from-literal=DATABASE_URL='postgresql+psycopg://flycatch_website:PASSWORD@postgres.database.svc.cluster.local:5432/flycatch_website' \
+# --from-literal=S3_ACCESS_KEY='...' \
+# --from-literal=S3_SECRET_KEY='...' \
+# --from-literal=SESSION_SECRET='...' \
+# --from-literal=CSRF_SECRET='...' \
+# --from-literal=JWT_SECRET='...' \
+# --from-literal=BUILD_EXPORT_TOKEN='...'
+apiVersion: v1
+kind: Secret
+metadata:
+ name: flycatch-website-secrets
+ labels:
+ app.kubernetes.io/name: flycatch-website
+type: Opaque
+stringData:
+ DATABASE_URL: postgresql+psycopg://flycatch_website:REPLACE_ME_POSTGRES_PASSWORD@postgres.database.svc.cluster.local:5432/flycatch_website
+ S3_ACCESS_KEY: REPLACE_ME_MINIO_ACCESS_KEY
+ S3_SECRET_KEY: REPLACE_ME_MINIO_SECRET_KEY
+ SESSION_SECRET: REPLACE_ME_LONG_RANDOM_SESSION_SECRET
+ CSRF_SECRET: REPLACE_ME_LONG_RANDOM_CSRF_SECRET
+ JWT_SECRET: REPLACE_ME_LONG_RANDOM_JWT_SECRET
+ BUILD_EXPORT_TOKEN: REPLACE_ME_BUILD_EXPORT_TOKEN
diff --git a/deployment/k8s/scripts/deploy-dev.sh b/deployment/k8s/scripts/deploy-dev.sh
new file mode 100755
index 0000000..9dc1104
--- /dev/null
+++ b/deployment/k8s/scripts/deploy-dev.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+# Build + push dev images to Harbor and bump GitOps tags for Argo CD.
+# Requires: docker, kustomize, git; env HARBOR_USERNAME + HARBOR_PASSWORD.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+cd "$ROOT"
+
+REGISTRY="${REGISTRY:-registry.k3s.flycatchtech.in}"
+BACKEND_IMAGE="${BACKEND_IMAGE:-${REGISTRY}/flycatch-website/backend}"
+FRONTEND_IMAGE="${FRONTEND_IMAGE:-${REGISTRY}/flycatch-website/frontend}"
+ADMIN_FE_IMAGE="${ADMIN_FE_IMAGE:-${REGISTRY}/flycatch-website/administration-fe}"
+OVERLAY="deployment/k8s/overlays/dev"
+PUBLIC_ORIGIN="${PUBLIC_ORIGIN:-https://flycatch-website-dev.k3s.flycatchtech.in}"
+PUBLIC_ENVIRONMENT="${PUBLIC_ENVIRONMENT:-development}"
+
+die() {
+ echo "error: $*" >&2
+ exit 1
+}
+
+[[ -n "${HARBOR_USERNAME:-}" ]] || die "HARBOR_USERNAME is required"
+[[ -n "${HARBOR_PASSWORD:-}" ]] || die "HARBOR_PASSWORD is required"
+
+command -v docker >/dev/null || die "docker is required"
+command -v kustomize >/dev/null || die "kustomize is required on PATH"
+command -v git >/dev/null || die "git is required"
+
+if [[ -n "$(git status --porcelain)" ]]; then
+ die "working tree is dirty; commit or stash changes before deploying"
+fi
+
+TAG="$(git rev-parse HEAD)"
+echo "==> tag ${TAG}"
+echo "==> PUBLIC_ORIGIN=${PUBLIC_ORIGIN}"
+echo "==> PUBLIC_ENVIRONMENT=${PUBLIC_ENVIRONMENT}"
+
+echo "==> docker login ${REGISTRY}"
+echo "${HARBOR_PASSWORD}" | docker login "${REGISTRY}" -u "${HARBOR_USERNAME}" --password-stdin
+
+echo "==> build backend (linux/amd64)"
+docker build --platform linux/amd64 -f apps/Backend/Dockerfile \
+ -t "${BACKEND_IMAGE}:${TAG}" \
+ -t "${BACKEND_IMAGE}:latest" \
+ apps/Backend
+
+echo "==> build frontend (linux/amd64)"
+docker build --platform linux/amd64 -f apps/Frontend/Dockerfile \
+ --build-arg "PUBLIC_ORIGIN=${PUBLIC_ORIGIN}" \
+ --build-arg "PUBLIC_ENVIRONMENT=${PUBLIC_ENVIRONMENT}" \
+ -t "${FRONTEND_IMAGE}:${TAG}" \
+ -t "${FRONTEND_IMAGE}:latest" \
+ apps/Frontend
+
+echo "==> build administration-fe (linux/amd64)"
+docker build --platform linux/amd64 -f apps/Administration-FE/Dockerfile \
+ --build-context "specs=specs" \
+ --build-arg "PUBLIC_ORIGIN=${PUBLIC_ORIGIN}" \
+ --build-arg "PUBLIC_ENVIRONMENT=${PUBLIC_ENVIRONMENT}" \
+ -t "${ADMIN_FE_IMAGE}:${TAG}" \
+ -t "${ADMIN_FE_IMAGE}:latest" \
+ apps/Administration-FE
+
+echo "==> push images"
+docker push "${BACKEND_IMAGE}:${TAG}"
+docker push "${BACKEND_IMAGE}:latest"
+docker push "${FRONTEND_IMAGE}:${TAG}"
+docker push "${FRONTEND_IMAGE}:latest"
+docker push "${ADMIN_FE_IMAGE}:${TAG}"
+docker push "${ADMIN_FE_IMAGE}:latest"
+
+echo "==> bump kustomize image tags"
+(
+ cd "${OVERLAY}"
+ kustomize edit set image \
+ "${BACKEND_IMAGE}=${BACKEND_IMAGE}:${TAG}" \
+ "${FRONTEND_IMAGE}=${FRONTEND_IMAGE}:${TAG}" \
+ "${ADMIN_FE_IMAGE}=${ADMIN_FE_IMAGE}:${TAG}"
+)
+
+echo "==> commit and push GitOps tag bump"
+git add "${OVERLAY}/kustomization.yaml"
+if git diff --staged --quiet; then
+ echo "Image tags already up to date; nothing to commit"
+else
+ git commit -m "chore(deploy): bump dev images to ${TAG}"
+ git push origin HEAD
+fi
+
+echo
+echo "Done. Argo CD should sync shortly."
+echo " kubectl -n argocd get application flycatch-website-dev"
+echo " kubectl -n flycatch-website-dev get pods,ingress"
diff --git a/docs/onboarding.md b/docs/onboarding.md
index b62b5f8..740d8a1 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -4,14 +4,14 @@
Docker Compose starts Frontend, Administration FE, Backend, PostgreSQL, MinIO, and the gateway. It does **not** create staff users or apply migrations. There is no default login and no sign-up screen.
-1. `cp deployment/.env.example deployment/.env` and set `JWT_SECRET` (and other `change-me` values) to long random secrets. Do not commit `deployment/.env`.
-2. `docker compose -f deployment/docker-compose.yml up -d --build`
-3. Backend migrations: `docker compose -f deployment/docker-compose.yml exec backend alembic upgrade head`
-4. Seed records: `docker compose -f deployment/docker-compose.yml exec backend flycatch-seed-records`
+1. `cp deployment/compose/.env.example deployment/compose/.env` and set `JWT_SECRET` (and other `change-me` values) to long random secrets. Do not commit `deployment/compose/.env`.
+2. `docker compose -f deployment/compose/docker-compose.yml up -d --build`
+3. Backend migrations: `docker compose -f deployment/compose/docker-compose.yml exec backend alembic upgrade head`
+4. Seed records: `docker compose -f deployment/compose/docker-compose.yml exec backend flycatch-seed-records`
5. Bootstrap default roles and two staff users:
```bash
- docker compose -f deployment/docker-compose.yml exec backend flycatch-bootstrap \
+ docker compose -f deployment/compose/docker-compose.yml exec backend flycatch-bootstrap \
--user-1-email admin1@example.com \
--user-2-email admin2@example.com \
--user-2-role editor
@@ -24,7 +24,7 @@ Docker Compose starts Frontend, Administration FE, Backend, PostgreSQL, MinIO, a
These emails are examples only. Passwords are **not** stored in the repo: they are prompted (minimum 12 characters) unless you pass `--user-1-password` and `--user-2-password`. Re-running with the same emails is idempotent and does not change existing passwords. Pytest fixtures (`editor1@example.com` / test passwords) are not created by this command.
-6. Later staff: `docker compose -f deployment/docker-compose.yml exec backend flycatch-provision-admin --email someone@example.com --role editor` (`--role` is required: `administrator` or `editor`).
+6. Later staff: `docker compose -f deployment/compose/docker-compose.yml exec backend flycatch-provision-admin --email someone@example.com --role editor` (`--role` is required: `administrator` or `editor`).
7. Generate Administration FE types: `cd apps/Administration-FE && npm run generate:client`
8. Build Frontend: `cd apps/Frontend && pnpm install && pnpm run build`
diff --git a/specs/001-website-foundation/quickstart.md b/specs/001-website-foundation/quickstart.md
index 069bfc8..3b10780 100644
--- a/specs/001-website-foundation/quickstart.md
+++ b/specs/001-website-foundation/quickstart.md
@@ -15,13 +15,13 @@ Related artifacts: [spec.md](./spec.md), [data-model.md](./data-model.md), [cont
## Setup
-1. Copy environment config: `cp deployment/.env.example deployment/.env` and adjust values.
-2. Start all services: `docker compose -f deployment/docker-compose.yml up -d --build` (see [README.md](../../README.md#quick-start-docker-compose)).
+1. Copy environment config: `cp deployment/compose/.env.example deployment/compose/.env` and adjust values.
+2. Start all services: `docker compose -f deployment/compose/docker-compose.yml up -d --build` (see [README.md](../../README.md#quick-start-docker-compose)).
3. Apply Backend migrations, seed records, and bootstrap two staff users (`flycatch-bootstrap`). Compose does not create a default login. Full commands: [docs/onboarding.md](../../docs/onboarding.md).
4. Generate OpenAPI consumers for Frontend and Administration FE from `specs/001-website-foundation/contracts/`; confirm Backend served OpenAPI matches the same files.
5. Export the published snapshot (empty or seed `home` + `site_settings`) into `apps/Frontend/src/data/published.json`.
6. Build `apps/Frontend` with `astro build` (`output: 'static'`).
-7. Rebuild compose services when app images change: `docker compose -f deployment/docker-compose.yml up -d --build`.
+7. Rebuild compose services when app images change: `docker compose -f deployment/compose/docker-compose.yml up -d --build`.
8. Open the gateway origin from `.env` (default `http://localhost:8080`): `/` → Frontend, `/admin` → Administration FE, `/api` → Backend.
Do not point the public site at live API URLs for ordinary browsing.
@@ -95,7 +95,7 @@ Do not point the public site at live API URLs for ordinary browsing.
| Environment | Public HTML | Admin / API |
| --- | --- | --- |
-| Local | `docker compose -f deployment/docker-compose.yml up` | Gateway origin from `.env` |
+| Local | `docker compose -f deployment/compose/docker-compose.yml up` | Gateway origin from `.env` |
| Preview | Same build command, HTTPS | HTTPS, production-like headers |
| Production | Same build command, cacheable assets, invalidate on new published revision | HTTPS, idle session timeout enforced |
diff --git a/specs/001-website-foundation/research.md b/specs/001-website-foundation/research.md
index d28c0e7..53ce9d0 100644
--- a/specs/001-website-foundation/research.md
+++ b/specs/001-website-foundation/research.md
@@ -204,7 +204,7 @@ Promotion is blocked when any gate fails (FR-041, FR-043, SC-006).
## 13. Environments and hosting
-**Decision**: Hosting vendor is out of scope (spec). Local/preview/production MUST share the same HTML production path: export published snapshot → `astro build` in `apps/Frontend` → deploy static files with cache-busting. All foundation services (Frontend, Administration FE, Backend, PostgreSQL, object storage) share `deployment/docker-compose.yml` and `.env` configuration. Gateway provides one origin (`/`, `/admin`, `/api`).
+**Decision**: Hosting vendor is out of scope (spec). Local/preview/production MUST share the same HTML production path: export published snapshot → `astro build` in `apps/Frontend` → deploy static files with cache-busting. All foundation services (Frontend, Administration FE, Backend, PostgreSQL, object storage) share `deployment/compose/docker-compose.yml` and `.env` configuration. Gateway provides one origin (`/`, `/admin`, `/api`).
**Rationale**: FR-004, FR-030, FR-041. Equivalent public HTML for the same revision in preview and production. One deployment folder avoids duplicated compose/env setup.
diff --git a/specs/001-website-foundation/tasks.md b/specs/001-website-foundation/tasks.md
index f682d65..a048d66 100644
--- a/specs/001-website-foundation/tasks.md
+++ b/specs/001-website-foundation/tasks.md
@@ -40,10 +40,10 @@ description: "Task list for Website Foundation feature implementation"
- [x] T006 [P] Configure ESLint/Prettier for Frontend and Administration-FE (`apps/Frontend/`, `apps/Administration-FE/`)
- [x] T007 [P] Configure Ruff and pytest for Backend in `apps/Backend/pyproject.toml`
- [x] T008 [P] Add Dockerfiles for Frontend, Administration-FE, and Backend (`apps/Frontend/Dockerfile`, `apps/Administration-FE/Dockerfile`, `apps/Backend/Dockerfile`)
-- [x] T009 Complete `deployment/docker-compose.yml`, `deployment/Caddyfile`, and `deployment/.env.example` for gateway path split (`/`, `/admin`, `/api`)
+- [x] T009 Complete `deployment/compose/docker-compose.yml`, `deployment/k8s/base/Caddyfile`, and `deployment/compose/.env.example` for gateway path split (`/`, `/admin`, `/api`)
- [x] T010 Write root `README.md` with project overview, deployment usage, and contract consumption rules
-**Checkpoint**: All three apps scaffolded; `docker compose -f deployment/docker-compose.yml up` starts services (may serve placeholders)
+**Checkpoint**: All three apps scaffolded; `docker compose -f deployment/compose/docker-compose.yml up` starts services (may serve placeholders)
---
diff --git a/specs/002-auth-rbac/quickstart.md b/specs/002-auth-rbac/quickstart.md
index 9a6cb9b..17abc84 100644
--- a/specs/002-auth-rbac/quickstart.md
+++ b/specs/002-auth-rbac/quickstart.md
@@ -14,10 +14,10 @@ Related artifacts: [spec.md](./spec.md), [data-model.md](./data-model.md), [cont
## Setup
-1. Copy or update `deployment/.env` with `jwt_secret` (long random) in addition to existing Backend secrets.
-2. Start services: `docker compose -f deployment/docker-compose.yml up -d --build` (see [README.md](../../README.md#quick-start-docker-compose)).
+1. Copy or update `deployment/compose/.env` with `jwt_secret` (long random) in addition to existing Backend secrets.
+2. Start services: `docker compose -f deployment/compose/docker-compose.yml up -d --build` (see [README.md](../../README.md#quick-start-docker-compose)).
3. Apply Backend migrations (includes roles and refresh-session columns).
-4. Run bootstrap (see [bootstrap.cli.yaml](./contracts/bootstrap.cli.yaml)). Prefix with `docker compose -f deployment/docker-compose.yml exec backend` when using Compose:
+4. Run bootstrap (see [bootstrap.cli.yaml](./contracts/bootstrap.cli.yaml)). Prefix with `docker compose -f deployment/compose/docker-compose.yml exec backend` when using Compose:
```bash
flycatch-bootstrap \
diff --git a/specs/002-auth-rbac/tasks.md b/specs/002-auth-rbac/tasks.md
index 489431a..f795ffb 100644
--- a/specs/002-auth-rbac/tasks.md
+++ b/specs/002-auth-rbac/tasks.md
@@ -34,7 +34,7 @@ description: "Task list for Authentication and Authorisation (RBAC) feature impl
- [x] T001 Add `PyJWT` to Backend dependencies in `apps/Backend/pyproject.toml`
- [x] T002 [P] Add `jwt_secret` and `jwt_access_minutes` (default 15) settings in `apps/Backend/src/flycatch_api/config.py`
-- [x] T003 [P] Add `JWT_SECRET` and `JWT_ACCESS_MINUTES` placeholders (no well-known staff passwords) in `deployment/.env.example`
+- [x] T003 [P] Add `JWT_SECRET` and `JWT_ACCESS_MINUTES` placeholders (no well-known staff passwords) in `deployment/compose/.env.example`
- [x] T004 [P] Point Administration FE OpenAPI generation at `admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, and `publish.v2` in `apps/Administration-FE/scripts/generate-client.mjs` and `apps/Administration-FE/package.json`
- [x] T005 [P] Extend `scripts/validate-contracts.mjs` to validate OpenAPI YAML under `specs/002-auth-rbac/contracts/` (skip `bootstrap.cli.yaml`)
- [x] T006 Register `flycatch-bootstrap` console script in `apps/Backend/pyproject.toml`