SYS-18: add production deployment stack for karbe.cosmolan.fr (Stripe test)

- enable Next.js standalone output and add Docker/Caddy production stack
- add production env template and deployment runbook
- add healthcheck endpoint for container supervision
- fix existing lint/type blockers discovered during validation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Karbé Architect 2026-05-30 18:01:56 +00:00
parent 0a366c65db
commit c9be24a969
11 changed files with 201 additions and 5 deletions

View file

@ -10,6 +10,12 @@ AUTH_SECRET="changeme"
# URL publique du site, utilisée pour résoudre les URLs canoniques et
# OpenGraph (SEO). En développement, laissez la valeur par défaut.
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
APP_URL="http://localhost:3000"
# Stripe (mode test recommandé pour le MVP)
STRIPE_SECRET_KEY="sk_test_xxx"
STRIPE_WEBHOOK_SECRET="whsec_xxx"
STRIPE_OWNER_SUBSCRIPTION_PRICE_ID="price_xxx"
# Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO.
# Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000)

31
.env.production.example Normal file
View file

@ -0,0 +1,31 @@
NODE_ENV=production
PORT=3000
# Domain
NEXT_PUBLIC_SITE_URL=https://karbe.cosmolan.fr
APP_URL=https://karbe.cosmolan.fr
# Security
NEXTAUTH_SECRET=replace_with_random_secret
AUTH_SECRET=replace_with_random_secret
# Database (managed PostgreSQL recommended)
DATABASE_URL=postgresql://user:password@db-host:5432/karbe?schema=public
# Stripe TEST
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_OWNER_SUBSCRIPTION_PRICE_ID=price_xxx
# Storage S3 / MinIO
S3_ENDPOINT=https://s3.example.com
S3_REGION=eu-west-3
S3_BUCKET=karbe-medias
S3_ACCESS_KEY_ID=replace_me
S3_SECRET_ACCESS_KEY=replace_me
S3_PUBLIC_URL=https://cdn.example.com/karbe-medias
S3_FORCE_PATH_STYLE=false
# Recommended for stable multi-instance deploys
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace_with_base64_32_bytes
DEPLOYMENT_VERSION=manual-v1

1
.gitignore vendored
View file

@ -33,6 +33,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.production.example
# vercel
.vercel

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

View file

@ -15,6 +15,7 @@ et payer leur séjour en ligne.
- [Prérequis](#prérequis)
- [Installation](#installation)
- [Variables d'environnement](#variables-denvironnement)
- [Déploiement production (karbe.cosmolan.fr)](#déploiement-production-karbecosmolanfr)
- [Développement](#développement)
- [Base de données (Prisma)](#base-de-données-prisma)
- [Scripts npm](#scripts-npm)
@ -115,11 +116,70 @@ Copiez-le en `.env` et renseignez vos valeurs.
| --- | --- |
| `DATABASE_URL` | Chaîne de connexion PostgreSQL utilisée par Prisma. |
| `AUTH_SECRET` / `NEXTAUTH_SECRET` | Secret de signature des sessions NextAuth. Générer avec `openssl rand -base64 32`. |
| `NEXT_PUBLIC_SITE_URL` / `APP_URL` | URL publique de l'application (ex: `https://karbe.cosmolan.fr`). |
| `STRIPE_SECRET_KEY` | Clé secrète Stripe (mode test pour MVP). |
| `STRIPE_WEBHOOK_SECRET` | Secret du webhook Stripe. |
| `STRIPE_OWNER_SUBSCRIPTION_PRICE_ID` | Price ID de l'abonnement loueur Stripe. |
> Le fichier `.env` ne doit **jamais** être commité (il est ignoré par Git).
> Au fur et à mesure de l'ajout des intégrations (ex. Stripe), de nouvelles
> variables seront ajoutées à `.env.example`.
## Déploiement production (karbe.cosmolan.fr)
Le repo inclut une stack de self-hosting Docker pour le MVP:
- `Dockerfile` (build Next.js standalone)
- `docker-compose.prod.yml` (app + reverse proxy Caddy HTTPS)
- `docker/Caddyfile`
- `.env.production.example`
### 1) Préparer le serveur
- Docker Engine + Docker Compose plugin installés.
- DNS `A`/`AAAA` de `karbe.cosmolan.fr` pointant vers le serveur.
- Ports `80` et `443` ouverts.
### 2) Configurer l'environnement
```bash
cp .env.production.example .env.production
```
Renseigner toutes les variables, en particulier `DATABASE_URL`,
`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_OWNER_SUBSCRIPTION_PRICE_ID`,
`APP_URL` et `NEXT_PUBLIC_SITE_URL`.
### 3) Appliquer les migrations Prisma
Les migrations doivent être appliquées avant le premier démarrage :
```bash
npx prisma migrate deploy
```
### 4) Lancer la stack
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
Vérifier la santé:
```bash
curl -fsS https://karbe.cosmolan.fr/api/health
```
### 5) Connecter le webhook Stripe (TEST)
Depuis un poste ayant Stripe CLI:
```bash
stripe listen --forward-to https://karbe.cosmolan.fr/api/stripe/webhook
```
Copier le secret affiché (`whsec_...`) dans `STRIPE_WEBHOOK_SECRET`, puis redéployer.
## Développement
```bash

42
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,42 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: karbe-app
restart: unless-stopped
env_file:
- .env.production
networks:
- karbe
healthcheck:
test: ["CMD", "wget", "-qO", "-", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
caddy:
image: caddy:2.10-alpine
container_name: karbe-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
depends_on:
app:
condition: service_healthy
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- karbe
networks:
karbe:
name: karbe-net
volumes:
caddy_data:
caddy_config:

14
docker/Caddyfile Normal file
View file

@ -0,0 +1,14 @@
{
email ops@cosmolan.fr
}
karbe.cosmolan.fr {
encode zstd gzip
reverse_proxy app:3000
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}

View file

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View file

@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({ status: "ok" });
}

View file

@ -97,11 +97,16 @@ async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const currentPeriodEnd =
subscription.items.data[0]?.current_period_end ??
subscription.trial_end ??
subscription.canceled_at;
await prisma.subscription.upsert({
where: { providerSubId: subscription.id },
update: {
status: mapStripeSubscriptionStatus(subscription.status),
renewedAt: fromStripeTimestamp(subscription.current_period_end),
renewedAt: fromStripeTimestamp(currentPeriodEnd),
canceledAt: fromStripeTimestamp(subscription.canceled_at),
},
create: {
@ -111,7 +116,7 @@ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
providerSubId: subscription.id,
status: mapStripeSubscriptionStatus(subscription.status),
startedAt: fromStripeTimestamp(subscription.start_date) ?? new Date(),
renewedAt: fromStripeTimestamp(subscription.current_period_end),
renewedAt: fromStripeTimestamp(currentPeriodEnd),
canceledAt: fromStripeTimestamp(subscription.canceled_at),
},
});

View file

@ -1,3 +1,5 @@
import Link from "next/link";
import type { CarbetSearchFilters } from "@/lib/carbet-search";
type SearchFiltersProps = {
@ -72,12 +74,12 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
</label>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5 lg:justify-end">
<a
<Link
href="/carbets"
className="rounded-md border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50"
>
Réinitialiser
</a>
</Link>
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"