diff --git a/.env.example b/.env.example index 004e1ae..3704df8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ # MQTT Mosquitto credentials MQTT_USERNAME=pinball_user MQTT_PASSWORD=change_me + +# PostgreSQL +POSTGRES_DB=pinball_db +POSTGRES_USER=pinball_user +POSTGRES_PASSWORD=change_me diff --git a/README.md b/README.md index e8d82ce..3ec5464 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Pinball Three.js -Ce projet a pour ambition de recréer l’expérience d’un flipper physique à travers une architecture web moderne, distribuée et interactive. -L’objectif n’est pas seulement de reproduire un jeu, mais de concevoir un système complet temps réel, combinant : +Ce projet a pour ambition de recréer l'expérience d'un flipper physique à travers une architecture web moderne, distribuée et interactive. +L'objectif n'est pas seulement de reproduire un jeu, mais de concevoir un système complet temps réel, combinant : - Simulation physique 3D - Communication distribuée (WebSockets + MQTT) - Intégration IoT - Expérience utilisateur immersive -Le projet vise à démontrer la capacité à concevoir une architecture logicielle complexe inspirée d’un système réel (flipper mécanique) tout en exploitant les technologies web contemporaines. -Ce projet est réalisé dans le cadre validation de la troisième année de _Bachelor Développeur Web_ à HETIC, et s’inscrit dans une démarche d’apprentissage par la pratique, en appliquant les concepts de développement web, d’architecture logicielle et de communication temps réel. +Le projet vise à démontrer la capacité à concevoir une architecture logicielle complexe inspirée d'un système réel (flipper mécanique) tout en exploitant les technologies web contemporaines. +Ce projet est réalisé dans le cadre validation de la troisième année de _Bachelor Développeur Web_ à HETIC, et s'inscrit dans une démarche d'apprentissage par la pratique, en appliquant les concepts de développement web, d'architecture logicielle et de communication temps réel. --- @@ -17,12 +17,75 @@ Ce projet est réalisé dans le cadre validation de la troisième année de _Bac Ce projet est contenu dans un conteneur Docker. Pour le lancer, utilisez la commande suivante dans le terminal à la racine du projet : -**Développement :** - ```bash docker compose -f compose.dev.yml up -d ``` +Services principaux disponibles en développement : + +- Frontend : `http://localhost:5173` +- Backend API : `http://localhost:3000` +- Drizzle Studio backend : `localhost:4983` +- PostgreSQL : `localhost:5432` +- Mosquitto : `localhost:1883` + +--- + +## Base de données + +La base de données du projet repose sur : + +- PostgreSQL +- Drizzle ORM +- Drizzle Studio pour la visualisation en développement + +Tables actuellement initialisées : + +- `users` +- `games` +- `scores` + +### Exécuter les migrations en développement + +Démarrer les services nécessaires : + +```bash +docker compose -f compose.dev.yml up -d postgres backend drizzle-studio +``` + +Appliquer les migrations Drizzle depuis le conteneur backend : + +```bash +docker compose -f compose.dev.yml exec backend pnpm db:migrate +``` + +### Générer une nouvelle migration + +Après modification du schéma dans `backend/src/db/schema.ts` : + +```bash +docker compose -f compose.dev.yml exec backend pnpm db:generate +docker compose -f compose.dev.yml exec backend pnpm db:migrate +``` + +### Accéder à la base avec Drizzle Studio + +Ouvrir `https://local.drizzle.studio`. + +Le service `drizzle-studio` est disponible uniquement en développement. +Le port `4983` correspond au backend local utilisé par Drizzle Studio. + +### Production + +En production, les migrations sont exécutées par le service `migrate` avant le démarrage du backend. +Il n'y a pas d'interface d'administration de la base exposée. + +Commande de déploiement : + +```bash +docker compose -f compose.prod.yml up -d --build +``` + --- ## Tests diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a3e5b90 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.pnpm-store +.env +.git +.gitignore +README.md + +coverage +*.log diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f68a203 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.pnpm-store/* +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1ef1dbe --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,45 @@ +# --- Étape 1 : Toutes les dépendances (pour le build) --- +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile + +# --- Étape 2 : Construction du code (TypeScript -> JS) --- +FROM node:20-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json tsconfig.json drizzle.config.ts ./ +COPY drizzle ./drizzle +COPY src ./src +RUN corepack enable && pnpm run build + +# --- Étape 3 : Uniquement les dépendances de prod --- +FROM node:20-alpine AS prod-deps +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +# Le flag --prod ignore les devDependencies ! +RUN corepack enable && pnpm install --prod --frozen-lockfile + +# --- Étape 4 : Image finale (Runner) --- +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +# On active corepack une bonne fois pour toutes dans l'image +RUN corepack enable + +# Sécurité : On bascule sur l'utilisateur non privilégié +USER node + +# On récupère le strict minimum +COPY package.json ./ +COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/dist ./dist +COPY --chown=node:node drizzle.config.ts ./ +COPY --from=build --chown=node:node /app/drizzle ./drizzle + +EXPOSE 3000 + +# Commande de démarrage épurée +CMD ["pnpm", "run", "start"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f66a43b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,76 @@ +# Backend + +Le backend fournit : + +- l'API Express +- la connexion PostgreSQL +- le schema Drizzle +- les migrations SQL + +## Structure + +- `src/server.ts` : point d'entree de l'API +- `src/db/client.ts` : client PostgreSQL et Drizzle +- `src/db/schema.ts` : definition des tables +- `drizzle/` : migrations generees +- `drizzle.config.ts` : configuration Drizzle Kit + +## Scripts + +Depuis `backend/` : + +```bash +pnpm dev +pnpm build +pnpm db:generate +pnpm db:migrate +pnpm db:studio +``` + +## Workflow recommande + +1. Modifier le schema dans `src/db/schema.ts` +2. Generer la migration +3. Appliquer la migration +4. Verifier le resultat dans Drizzle Studio + +Exemple via Docker : + +```bash +docker compose -f compose.dev.yml up -d postgres backend drizzle-studio +docker compose -f compose.dev.yml exec backend pnpm db:generate +docker compose -f compose.dev.yml exec backend pnpm db:migrate +``` + +## Tables actuelles + +- `users` +- `games` +- `scores` + +Relations : + +- `games.user_id -> users.id` +- `scores.game_id -> games.id` + +## Verification rapide + +Verifier la sante de l'API : + +```bash +curl http://localhost:3000/health +``` + +Lister les tables PostgreSQL : + +```bash +docker compose -f compose.dev.yml exec postgres psql -U pinball_user -d pinball_db -c "\dt" +``` + +Ouvrir Drizzle Studio : + +```text +https://local.drizzle.studio +``` + +Le port `4983` est expose localement pour le backend de Drizzle Studio. diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts new file mode 100644 index 0000000..e9c5739 --- /dev/null +++ b/backend/drizzle.config.ts @@ -0,0 +1,19 @@ +import "dotenv/config"; +import { defineConfig } from "drizzle-kit"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required to generate or run migrations."); +} + +export default defineConfig({ + out: "./drizzle", + schema: "./src/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: databaseUrl, + }, + verbose: true, + strict: true, +}); diff --git a/backend/drizzle/0000_thick_meltdown.sql b/backend/drizzle/0000_thick_meltdown.sql new file mode 100644 index 0000000..a9782ac --- /dev/null +++ b/backend/drizzle/0000_thick_meltdown.sql @@ -0,0 +1,30 @@ +CREATE TABLE "users" ( + "id" serial PRIMARY KEY NOT NULL, + "username" varchar(255) NOT NULL, + "email" varchar(255) NOT NULL, + "password_hash" varchar(255) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE("username"), + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "games" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "played_duration_seconds" integer NOT NULL, + "final_score" integer NOT NULL, + "played_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scores" ( + "id" serial PRIMARY KEY NOT NULL, + "game_id" integer NOT NULL, + "points_earned" integer NOT NULL, + "collision_event" varchar(255) NOT NULL, + "game_timestamp" double precision NOT NULL +); +--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "games_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "scores" ADD CONSTRAINT "scores_game_id_games_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON DELETE cascade ON UPDATE no action; diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..0761a26 --- /dev/null +++ b/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,200 @@ +{ + "id": "fc827376-dca5-4239-9357-7f78bd36a671", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.games": { + "name": "games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "played_duration_seconds": { + "name": "played_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_score": { + "name": "final_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "games_user_id_users_id_fk": { + "name": "games_user_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scores": { + "name": "scores", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "points_earned": { + "name": "points_earned", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collision_event": { + "name": "collision_event", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "game_timestamp": { + "name": "game_timestamp", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "scores_game_id_games_id_fk": { + "name": "scores_game_id_games_id_fk", + "tableFrom": "scores", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..79ebf60 --- /dev/null +++ b/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1776325567614, + "tag": "0000_thick_meltdown", + "breakpoints": true + } + ] +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..bdf4192 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "backend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio --host 0.0.0.0 --port 4983" + }, + "dependencies": { + "dotenv": "^17.2.3", + "drizzle-orm": "^0.44.6", + "express": "^5.1.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "drizzle-kit": "^0.31.5", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts new file mode 100644 index 0000000..5f214fb --- /dev/null +++ b/backend/src/db/client.ts @@ -0,0 +1,11 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +import { env } from "../env.js"; +import * as schema from "./schema.js"; + +export const pool = new Pool({ + connectionString: env.databaseUrl, +}); + +export const db = drizzle(pool, { schema }); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts new file mode 100644 index 0000000..3072389 --- /dev/null +++ b/backend/src/db/schema.ts @@ -0,0 +1,37 @@ +import { + doublePrecision, + integer, + pgTable, + serial, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: serial("id").primaryKey(), + username: varchar("username", { length: 255 }).notNull().unique(), + email: varchar("email", { length: 255 }).notNull().unique(), + passwordHash: varchar("password_hash", { length: 255 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const games = pgTable("games", { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + playedDurationSeconds: integer("played_duration_seconds").notNull(), + finalScore: integer("final_score").notNull(), + playedAt: timestamp("played_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const scores = pgTable("scores", { + id: serial("id").primaryKey(), + gameId: integer("game_id") + .notNull() + .references(() => games.id, { onDelete: "cascade" }), + pointsEarned: integer("points_earned").notNull(), + collisionEvent: varchar("collision_event", { length: 255 }).notNull(), + gameTimestamp: doublePrecision("game_timestamp").notNull(), +}); diff --git a/backend/src/env.ts b/backend/src/env.ts new file mode 100644 index 0000000..441e847 --- /dev/null +++ b/backend/src/env.ts @@ -0,0 +1,17 @@ +import "dotenv/config"; + +function requireEnv(name: string): string { + const value = process.env[name]; + + if (!value) { + throw new Error(`${name} is required.`); + } + + return value; +} + +export const env = { + nodeEnv: process.env.NODE_ENV ?? "development", + port: Number.parseInt(process.env.PORT ?? "3000", 10), + databaseUrl: requireEnv("DATABASE_URL"), +}; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..745d22f --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,38 @@ +import express from "express"; +import { sql } from "drizzle-orm"; + +import { db, pool } from "./db/client.js"; +import { env } from "./env.js"; + +const app = express(); + +app.use(express.json()); + +app.get("/health", async (_request, response) => { + try { + await db.execute(sql`select 1`); + + response.json({ + status: "ok", + database: "connected", + }); + } catch (error) { + console.error("Health check failed:", error); + response.status(500).json({ + status: "error", + database: "disconnected", + }); + } +}); + +app.listen(env.port, () => { + console.log(`Backend listening on port ${env.port}`); +}); + +async function shutdown() { + await pool.end(); + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..9658096 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/compose.dev.yml b/compose.dev.yml index 1dbedc7..07d90ed 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,4 +1,58 @@ services: + postgres: + image: postgres:16-alpine + container_name: pinball-postgres + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + backend: + image: node:20-alpine + working_dir: /app + volumes: + - ./backend:/app + - /app/node_modules + ports: + - "3000:3000" + command: sh -c "corepack enable && pnpm install && pnpm dev" + + environment: + - NODE_ENV=development + - PORT=3000 + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + depends_on: + postgres: + condition: service_healthy + restart: always + + drizzle-studio: + image: node:20-alpine + working_dir: /app + volumes: + - ./backend:/app + - /app/node_modules + ports: + - "4983:4983" + command: sh -c "corepack enable && pnpm install && pnpm db:studio" + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + depends_on: + postgres: + condition: service_healthy + restart: always + + frontend: image: node:20-alpine working_dir: /app @@ -19,12 +73,12 @@ services: - MQTT_PASSWORD=${MQTT_PASSWORD} # On génère le fichier dans /config/ et on donne les droits à l'utilisateur mosquitto entrypoint: > - sh -c "touch /mosquitto/config/password_file.txt && - mosquitto_passwd -b /mosquitto/config/password_file.txt $$MQTT_USERNAME $$MQTT_PASSWORD && - chown 1883:1883 /mosquitto/config/password_file.txt && - chmod 600 /mosquitto/config/password_file.txt && - echo 'MQTT user created' && - exec mosquitto -c /mosquitto/config/mosquitto.conf" + sh -c "touch /mosquitto/config/password_file.txt && + mosquitto_passwd -b /mosquitto/config/password_file.txt $$MQTT_USERNAME $$MQTT_PASSWORD && + chown 1883:1883 /mosquitto/config/password_file.txt && + chmod 600 /mosquitto/config/password_file.txt && + echo 'MQTT user created' && + exec mosquitto -c /mosquitto/config/mosquitto.conf" ports: - "1883:1883" - "9001:9001" @@ -35,5 +89,6 @@ services: restart: always volumes: + postgres_data: mosquitto_data: mosquitto_log: diff --git a/compose.prod.yml b/compose.prod.yml index 5db2756..b1bb43d 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -1,4 +1,49 @@ services: + postgres: + image: postgres:16-alpine + container_name: pinball-postgres + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + migrate: + build: + context: ./backend + dockerfile: Dockerfile + command: ["npm", "run", "db:migrate"] + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + depends_on: + postgres: + condition: service_healthy + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + depends_on: + migrate: + condition: service_completed_successfully + restart: always + frontend: build: context: ./frontend @@ -31,5 +76,6 @@ services: restart: always volumes: + postgres_data: mosquitto_data: mosquitto_log: