diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43a1fd4..81bc18b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,3 +191,44 @@ jobs: - name: Arrêt et suppression des volumes if: always() run: docker compose down -v --remove-orphans + + docker-aio: + name: Docker tout-en-un (Unraid, NAS) + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + SMOKE_WAIT_SECONDS: 180 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Construction + run: DOCKER_BUILDKIT=1 docker build -f Dockerfile.aio -t parenthese-aio:test . + + - name: Démarrage, sans aucune variable + run: docker run -d --name aio -p 8080:80 -v aio-data:/data parenthese-aio:test + + - name: Test de fumée par le port web + run: bash tools/docker-smoke.sh http://localhost:8080 + + # Les secrets et la base de /data doivent servir tels quels au démarrage suivant (mise à jour, reboot). + - name: Redémarrage puis nouveau test + run: | + set -euo pipefail + docker restart aio + bash tools/docker-smoke.sh http://localhost:8080 + docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1 + + - name: Journaux en cas d'échec + if: failure() + run: | + docker ps -a + docker logs --timestamps aio || true + docker run --rm -v aio-data:/data busybox cat /data/postgres/postgres.log || true + + - name: Arrêt et suppression du volume + if: always() + run: | + docker rm -f aio || true + docker volume rm aio-data || true diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 9e10e15..b56340d 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -53,6 +53,10 @@ jobs: - image: parenthese-web context: . file: Dockerfile.web + # Tout-en-un (base, API, front) pour Unraid et les NAS + - image: parenthese-aio + context: . + file: Dockerfile.aio steps: # Sur workflow_run, le commit exact que la CI a validé, pas la tête de main au moment du run. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 170da9e..e0fd1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format. +## Unreleased + +### Self-hosting + +- Single-container image `ghcr.io/iliesallali/parenthese-aio` (PostgreSQL, API and front end), for Unraid and NAS systems that install one container per app. It needs no configuration: the database, its password and the session key are created in `/data` on first start. Tested in CI by a fresh start, a restart and the smoke test. + ## 1.0.2 (2026-10-09) ### Self-hosting diff --git a/Dockerfile.aio b/Dockerfile.aio new file mode 100644 index 0000000..8fa1e3c --- /dev/null +++ b/Dockerfile.aio @@ -0,0 +1,94 @@ +# syntax=docker/dockerfile:1 +# Parenthèse en un seul conteneur : PostgreSQL, API et front servi par nginx. Contexte de build : la racine du dépôt. +# Pour les systèmes qui installent une application par conteneur (Unraid, NAS). docker-compose.yml reste +# l'installation de référence, avec un conteneur par service. +# +# docker run -d --name parenthese -p 8080:80 -v parenthese-data:/data ghcr.io/iliesallali/parenthese-aio +# +# Tout ce qui doit survivre à une mise à jour vit dans /data : base (postgres/), photos et vidéos (media/), +# secrets générés au premier démarrage (secrets/). Voir deploy/aio/entrypoint.sh. + +# Étape 1 : API (mêmes étapes que backend/Dockerfile). +FROM node:24-bookworm-slim AS api-build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/package.json backend/package-lock.json ./ +COPY backend/prisma ./prisma +RUN npm ci --no-audit --no-fund +RUN npx prisma generate + +COPY backend/tsconfig.json ./ +COPY backend/src ./src +RUN npm run build + + +# Étape 2 : front (mêmes étapes que Dockerfile.web). +FROM node:24-alpine AS web-build + +WORKDIR /app + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ + +ENV VITE_API_BASE_URL="" +RUN npm run build + + +# Étape 3 : image finale. +FROM node:24-bookworm-slim + +# postgresql-common d'abord, pour qu'il ne crée pas de base par défaut à l'installation de PostgreSQL : +# la base vit dans /data et naît au premier démarrage. +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-common \ + && sed -i 's/^#\?\s*create_main_cluster.*/create_main_cluster = false/' /etc/postgresql-common/createcluster.conf \ + && apt-get install -y --no-install-recommends postgresql-15 nginx openssl ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /etc/nginx/sites-enabled/default \ + && mkdir -p /run/postgresql && chown postgres:postgres /run/postgresql + +WORKDIR /app + +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund \ + && npm cache clean --force + +# CLI Prisma à part, à la version du package-lock (voir backend/Dockerfile). +RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/prisma'].version")" \ + && mkdir -p /opt/prisma \ + && cd /opt/prisma \ + && echo '{"private":true}' > package.json \ + && npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \ + && npm cache clean --force + +COPY --from=api-build /app/node_modules/.prisma ./node_modules/.prisma +COPY backend/prisma ./prisma +COPY --from=api-build /app/dist ./dist + +# nginx : la configuration du conteneur web de compose, l'API étant ici sur la même machine. +COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf +RUN sed -i 's|http://api:4000|http://127.0.0.1:4000|g' /etc/nginx/conf.d/default.conf \ + && ! grep -q 'api:4000' /etc/nginx/conf.d/default.conf +COPY --from=web-build /app/dist /usr/share/nginx/html + +COPY deploy/aio/entrypoint.sh /usr/local/bin/parenthese-aio +RUN chmod 755 /usr/local/bin/parenthese-aio + +ENV NODE_ENV=production \ + CHECKPOINT_DISABLE=1 \ + PATH=/opt/prisma/node_modules/.bin:/usr/lib/postgresql/15/bin:$PATH + +VOLUME /data +EXPOSE 80 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \ + CMD node -e "fetch('http://127.0.0.1/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +ENTRYPOINT ["tini", "--", "/usr/local/bin/parenthese-aio"] diff --git a/Dockerfile.aio.dockerignore b/Dockerfile.aio.dockerignore new file mode 100644 index 0000000..be5cfb4 --- /dev/null +++ b/Dockerfile.aio.dockerignore @@ -0,0 +1,20 @@ +# Contexte de build de Dockerfile.aio (BuildKit lit ce fichier à la place de .dockerignore) : +# l'API, le front, la configuration nginx et le script de démarrage +* +!backend +!frontend +!deploy/nginx.docker.conf +!deploy/aio +backend/node_modules +backend/dist +backend/.local +backend/storage +backend/test +backend/openapi +backend/.env +backend/.env.* +frontend/node_modules +frontend/dist +frontend/.env +frontend/.env.* +**/*.log diff --git a/README.fr.md b/README.fr.md index 03d06c1..9b9d9b8 100644 --- a/README.fr.md +++ b/README.fr.md @@ -71,6 +71,16 @@ docker compose down # arrêt, les données sont conservées Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base. +### Un seul conteneur (Unraid, NAS) + +Pour les systèmes qui installent un conteneur par application, `ghcr.io/iliesallali/parenthese-aio` fait tourner la base, l'API et le front ensemble : + +```bash +docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /chemin/vers/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio +``` + +Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16. + ### Où sont les données Deux volumes Docker, conservés entre les redémarrages et les mises à jour : diff --git a/README.md b/README.md index 218661e..73aedee 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,16 @@ docker compose down # stop, data is kept To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database. +### Single container (Unraid, NAS) + +For systems that install one container per app, `ghcr.io/iliesallali/parenthese-aio` runs the database, the API and the front end together: + +```bash +docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /path/to/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio +``` + +There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16. + ### Where the data lives Two Docker volumes, kept across restarts and updates: diff --git a/deploy/aio/entrypoint.sh b/deploy/aio/entrypoint.sh new file mode 100755 index 0000000..edf3efe --- /dev/null +++ b/deploy/aio/entrypoint.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Démarrage de l'image tout-en-un (Dockerfile.aio) : PostgreSQL, puis l'API, puis nginx. +# +# Au premier démarrage, crée dans /data la base, le mot de passe de la base et la clé de signature des sessions +# (JWT_SECRET), gardés pour les démarrages suivants. Une variable JWT_SECRET passée au conteneur l'emporte sur +# la clé générée. Si l'un des trois processus s'arrête, le conteneur s'arrête, et le redémarrage automatique +# de Docker le relance. +set -euo pipefail + +DATA_DIR=/data +PGDATA="$DATA_DIR/postgres" +MEDIA_DIR="$DATA_DIR/media" +SECRETS_DIR="$DATA_DIR/secrets" +DB_NAME=parenthese +DB_USER=parenthese + +log() { echo "[parenthese] $*" >&2; } + +mkdir -p "$MEDIA_DIR" "$SECRETS_DIR" /run/postgresql +chmod 700 "$SECRETS_DIR" +chown postgres:postgres /run/postgresql +chown node:node "$MEDIA_DIR" + +# Secrets générés une fois, lisibles par root seulement. +secret() { + local file="$SECRETS_DIR/$1" + if [ ! -s "$file" ]; then + (umask 077 && openssl rand -hex "$2" > "$file") + log "secret $1 créé dans $file" + fi + cat "$file" +} + +DB_PASSWORD="$(secret db_password 24)" +if [ -z "${JWT_SECRET:-}" ]; then + JWT_SECRET="$(secret jwt_secret 48)" +fi + +# PostgreSQL, à l'écoute de la machine seule. +FIRST_START=0 +if [ ! -s "$PGDATA/PG_VERSION" ]; then + log "première installation : création de la base dans $PGDATA" + mkdir -p "$PGDATA" + chown postgres:postgres "$PGDATA" + chmod 700 "$PGDATA" + runuser -u postgres -- initdb -D "$PGDATA" --encoding=UTF8 --locale=C.UTF-8 \ + --auth-local=peer --auth-host=scram-sha-256 >/dev/null + FIRST_START=1 +fi +chown -R postgres:postgres "$PGDATA" +chmod 700 "$PGDATA" + +runuser -u postgres -- pg_ctl -D "$PGDATA" -w -t 60 \ + -o "-c listen_addresses=127.0.0.1 -c unix_socket_directories=/run/postgresql" \ + -l "$PGDATA/postgres.log" start >/dev/null + +if [ "$FIRST_START" = 1 ]; then + runuser -u postgres -- psql -q -v ON_ERROR_STOP=1 -c "CREATE ROLE $DB_USER LOGIN" + runuser -u postgres -- createdb -O "$DB_USER" "$DB_NAME" +fi +# Le mot de passe suit le fichier de /data à chaque démarrage (hexadécimal, sans guillemet possible). +runuser -u postgres -- psql -q -v ON_ERROR_STOP=1 -c "ALTER ROLE $DB_USER PASSWORD '$DB_PASSWORD'" + +export NODE_ENV=production +export PORT=4000 +export HOST=127.0.0.1 +export DATABASE_URL="postgresql://$DB_USER:$DB_PASSWORD@127.0.0.1:5432/$DB_NAME" +export MEDIA_STORAGE_PATH="$MEDIA_DIR" +export JWT_SECRET + +stop_all() { + log "arrêt" + [ -n "${NGINX_PID:-}" ] && kill -QUIT "$NGINX_PID" 2>/dev/null || true + [ -n "${API_PID:-}" ] && kill -TERM "$API_PID" 2>/dev/null || true + wait 2>/dev/null || true + runuser -u postgres -- pg_ctl -D "$PGDATA" -m fast -w stop >/dev/null 2>&1 || true +} +trap 'stop_all; exit 0' TERM INT + +cd /app +log "migrations" +runuser -u node -- env PATH="$PATH" prisma migrate deploy + +log "démarrage de l'API" +runuser -u node -- env PATH="$PATH" node dist/server.js & +API_PID=$! + +for _ in $(seq 1 60); do + if node -e "fetch('http://127.0.0.1:4000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then + break + fi + kill -0 "$API_PID" 2>/dev/null || { log "l'API s'est arrêtée au démarrage"; stop_all; exit 1; } + sleep 1 +done + +log "démarrage de nginx" +nginx -g 'daemon off;' & +NGINX_PID=$! + +log "prêt sur le port 80" +set +e +wait -n "$API_PID" "$NGINX_PID" +status=$? +log "un processus s'est arrêté (code $status)" +stop_all +exit "$status"