diff --git a/.env.docker.production.example b/.env.docker.production.example index fce8025..bb050cb 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -4,34 +4,32 @@ API_DOMAIN=api.rentaldrivego.ma PUBLIC_SITE_DOMAIN=rentaldrivego.ma PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma PORTAINER_DOMAIN=portainer.rentaldrivego.ma -REGISTRY_DOMAIN=registry.rentaldrivego.ma -REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000 + # ── Optional prebuilt image source ──────────────────────────────────────────── -# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION -# automatically during registry-backed deploys, so these can stay commented out. +# Used for production deployments. CI injects IMAGE_TAG automatically during +# registry-backed deploys; set it manually when running docker compose directly. +IMAGE_TAG=latest +# APP_IMAGE and APP_VERSION are still accepted by helper scripts for compatibility. # APP_IMAGE=registry.example.com/rentaldrivego/car_management_system # APP_VERSION=latest -REGISTRY_HOST=registry.rentaldrivego.ma -REGISTRY_USER=rentaldrivego_registry -REGISTRY_PASSWORD=placeholder -REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system -REGISTRY_HTTP_SECRET=placeholder + # ── Database ────────────────────────────────────────────────────────────────── # Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false) -DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder # Build the URL from individual vars (set to true to use POSTGRES_* vars below) +DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego DATABASE_URL_FROM_POSTGRES=true POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=rentaldrivego -POSTGRES_USER=postgres -POSTGRES_PASSWORD=placeholder +POSTGRES_USER=dbadmin +POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK # ── Cache ───────────────────────────────────────────────────────────────────── -REDIS_URL=redis://redis:6379 +REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK +REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379 # ── API ─────────────────────────────────────────────────────────────────────── API_PORT=4000 @@ -53,6 +51,7 @@ SITE_ORIGIN=https://rentaldrivego.ma # Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps) NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma +NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma NEXT_PUBLIC_STOREFRONT_URL=https://rentaldrivego.ma/storefront NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin @@ -67,7 +66,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma # ── Auth ────────────────────────────────────────────────────────────────────── -JWT_SECRET=placeholder +JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0 JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d NODE_ENV=production @@ -79,11 +78,12 @@ EMAIL_FROM_NAME=RentalDriveGo # Option A — Resend #RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V # Option B — SMTP (Gmail) +# SMTP fallback (only used if Resend fails or is unconfigured) MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD=placeholder +MAIL_PASSWORD=kfahihfzbcvkczew MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com diff --git a/.env.docker.production.generated b/.env.docker.production.generated index 54ee717..43ae4ca 100644 --- a/.env.docker.production.generated +++ b/.env.docker.production.generated @@ -7,6 +7,9 @@ PORTAINER_DOMAIN=portainer.example.com REGISTRY_DOMAIN=registry.example.com REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000 +# Image tag +IMAGE_TAG=latest + # Database DATABASE_URL_FROM_POSTGRES=true POSTGRES_HOST=postgres @@ -17,6 +20,7 @@ POSTGRES_PASSWORD=placeholder DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder # Cache +REDIS_PASSWORD=placeholder REDIS_URL=redis://redis:6379 # API @@ -27,6 +31,7 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api/v1 # Frontend public URLs NEXT_PUBLIC_MARKETING_URL=https://example.com +NEXT_PUBLIC_MARKETPLACE_URL=https://example.com NEXT_PUBLIC_HOMEPAGE_URL=https://example.com NEXT_PUBLIC_STOREFRONT_URL=https://example.com/storefront NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard diff --git a/.env.example b/.env.example index 20fce7b..e1c2178 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,13 @@ # ═══════════════════════════════════════════════════════════════ # ─── Database ────────────────────────────────────────────────── -DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder +DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego +DATABASE_URL_FROM_POSTGRES=true +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=rentaldrivego +POSTGRES_USER=dbadmin +POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK # ─── API ─────────────────────────────────────────────────────── API_PORT=4000 @@ -12,7 +18,7 @@ API_URL=http://localhost:4000 NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 # ─── JWT (Renter auth + Admin auth) ─────────────────────────── -JWT_SECRET=placeholder +JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d @@ -71,6 +77,7 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123 # MAIL_* SMTP variables are intentionally omitted here. # ─── Redis (Real-time / Socket.io) ──────────────────────────── +REDIS_PASSWORD=replace-with-production-redis-password REDIS_URL=redis://localhost:6379 # ─── App URLs ────────────────────────────────────────────────── @@ -80,19 +87,36 @@ REDIS_URL=redis://localhost:6379 NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000 NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000 # Public site is subdomain-based; use this for local dev: NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +API_DOMAIN=api.rentaldrivego.ma +PUBLIC_SITE_DOMAIN=rentaldrivego.ma DASHBOARD_URL=http://localhost:3000/dashboard # ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── ADMIN_SEED_EMAIL=admin@rentaldrivego.ma -ADMIN_SEED_PASSWORD=placeholder +ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK ADMIN_SEED_FIRST_NAME=Super ADMIN_SEED_LAST_NAME=Admin +# SMTP fallback (only used if Resend fails or is unconfigured) +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_SCHEME=smtp +MAIL_USERNAME=rentaldrivego@gmail.com +MAIL_PASSWORD=kfahihfzbcvkczew +MAIL_FROM_ADDRESS=rentaldrivego@gmail.com +MAIL_FROM_NAME=RentalDriveGo +MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com +MAIL_REPLY_TO_NAME=RentalDriveGo + + +IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0 + # ─── Misc ────────────────────────────────────────────────────── -NODE_ENV=development +NODE_ENV=production diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index ccfff65..89f7b27 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -13,7 +13,7 @@ env: NODE_VERSION: "20" # TODO: Remove after installing internal CA certificate on the runner GIT_SSL_NO_VERIFY: "true" - REGISTRY_HOST: 192.168.3.80 + REGISTRY_HOST: 10.0.0.4 DOCKERFILE_PATH: Dockerfile.production DOCKER_PLATFORM: linux/amd64 DEPLOY_ROOT: /opt/rentaldrivego @@ -41,7 +41,7 @@ jobs: uses: docker/setup-buildx-action@v3 with: buildkitd-config-inline: | - [registry."192.168.3.80"] + [registry."10.0.0.4"] insecure = true - name: Docker image metadata @@ -190,6 +190,7 @@ jobs: cd '$DEPLOY_ROOT' APP_IMAGE='$IMAGE_REPOSITORY' \ APP_VERSION='${{ gitea.sha }}' \ + IMAGE_TAG='${{ gitea.sha }}' \ REGISTRY_HOST='$REGISTRY_HOST' \ REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \ REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \ diff --git a/.gitignore b/.gitignore index 564c677..17e0b95 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ apps/api/storage/ .env .env.local .env.*.local +.env.docker.production +production/.env.docker.production # Turbo .turbo diff --git a/Dockerfile.production b/Dockerfile.production index 621364a..28ad0be 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -44,7 +44,8 @@ FROM node:20-bookworm AS runner WORKDIR /app -ENV NODE_ENV=production +ENV NODE_ENV=production \ + NPM_CONFIG_UPDATE_NOTIFIER=false RUN corepack enable && corepack prepare npm@10.5.0 --activate \ && groupadd --system --gid 10001 app \ @@ -56,6 +57,7 @@ COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /ap COPY --from=builder --chown=app:app /app/node_modules ./node_modules COPY --from=builder --chown=app:app /app/apps ./apps COPY --from=builder --chown=app:app /app/packages ./packages +COPY --from=builder --chown=app:app /app/config ./config COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh diff --git a/apps/dashboard.zip b/apps/dashboard.zip deleted file mode 100644 index e879a7a..0000000 Binary files a/apps/dashboard.zip and /dev/null differ diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 59da74b..ee5f7a7 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -2,6 +2,8 @@ name: rentaldrivego-prod x-node-runtime-hardening: &node-runtime-hardening init: true + user: "10001:10001" + entrypoint: [] security_opt: - no-new-privileges:true tmpfs: @@ -14,11 +16,11 @@ services: networks: - internal environment: - POSTGRES_DB: rentaldrivego - POSTGRES_USER: postgres + POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego} + POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d rentaldrivego"] + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 5s timeout: 5s retries: 10 @@ -44,9 +46,7 @@ services: migrate: <<: *node-runtime-hardening - cap_drop: - - ALL - image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.production @@ -56,8 +56,11 @@ services: ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} command: ["npm", "run", "db:deploy"] networks: - internal @@ -65,7 +68,7 @@ services: postgres: condition: service_healthy env_file: - - .env.docker.production + - ${APP_ENV_FILE:-.env.production} environment: DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} @@ -76,7 +79,7 @@ services: api: <<: *node-runtime-hardening - image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.production @@ -86,8 +89,11 @@ services: ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 15s @@ -104,7 +110,7 @@ services: redis: condition: service_healthy env_file: - - .env.docker.production + - ${APP_ENV_FILE:-.env.production} environment: DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true} FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage @@ -132,9 +138,7 @@ services: homepage: <<: *node-runtime-hardening - cap_drop: - - ALL - image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.production @@ -144,8 +148,11 @@ services: ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 15s @@ -160,7 +167,7 @@ services: api: condition: service_healthy env_file: - - .env.docker.production + - ${APP_ENV_FILE:-.env.production} restart: unless-stopped labels: - traefik.enable=true @@ -175,9 +182,7 @@ services: storefront: <<: *node-runtime-hardening - cap_drop: - - ALL - image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.production @@ -186,9 +191,12 @@ services: DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3004/', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 15s @@ -203,7 +211,7 @@ services: api: condition: service_healthy env_file: - - .env.docker.production + - ${APP_ENV_FILE:-.env.production} restart: unless-stopped labels: - traefik.enable=true @@ -218,9 +226,7 @@ services: dashboard: <<: *node-runtime-hardening - cap_drop: - - ALL - image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.production @@ -230,8 +236,11 @@ services: ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/dashboard', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 15s @@ -246,7 +255,7 @@ services: api: condition: service_healthy env_file: - - .env.docker.production + - ${APP_ENV_FILE:-.env.production} restart: unless-stopped labels: - traefik.enable=true @@ -261,9 +270,7 @@ services: admin: <<: *node-runtime-hardening - cap_drop: - - ALL - image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.production @@ -273,8 +280,11 @@ services: ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/admin', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 15s @@ -289,7 +299,7 @@ services: api: condition: service_healthy env_file: - - .env.docker.production + - ${APP_ENV_FILE:-.env.production} restart: unless-stopped labels: - traefik.enable=true @@ -333,3 +343,5 @@ volumes: redis_prod_data: pgmanage_prod_data: api_uploads: + external: true + name: ${DOCKER_PROD_API_UPLOADS_VOLUME:-rentaldrivego-prod_api_uploads} diff --git a/production/.env.docker.production.example b/production/.env.docker.production.example new file mode 100644 index 0000000..d4e33a7 --- /dev/null +++ b/production/.env.docker.production.example @@ -0,0 +1,84 @@ +# Traefik domains +ACME_EMAIL=ops@example.com +API_DOMAIN=api.rentaldrivego.ma +PUBLIC_SITE_DOMAIN=rentaldrivego.ma +PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma + +# Image tag +IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0 +# Optional prebuilt image source. Set this when deploying from a private registry. +APP_IMAGE=10.0.0.4/melabidi/carmanagement +# APP_VERSION is accepted by helper scripts for compatibility, but IMAGE_TAG is preferred. +# APP_VERSION=latest + +# External image registry host. REGISTRY_USER and REGISTRY_PASSWORD are only needed when docker login is required. +REGISTRY_HOST=10.0.0.4 +REGISTRY_USER=melabidi +REGISTRY_PASSWORD= + +# Optional shared folder for image archives created by the VPN/registry helper container. +# The VPS cannot reach the VPN-only registry, so archive loading is required. +IMAGE_ARCHIVE_DIR=/opt/docker-image-transfer +IMAGE_ARCHIVE_REQUIRED=true + +# Database +DATABASE_URL_FROM_POSTGRES=true +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=rentaldrivego +POSTGRES_USER=dbadmin +POSTGRES_PASSWORD=placeholder +DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder + +# Cache +REDIS_PASSWORD=placeholder +REDIS_URL=redis://:placeholder@redis:6379 + +# API +API_PORT=4000 +API_INTERNAL_URL=http://api:4000/api/v1 +DASHBOARD_INTERNAL_URL=http://dashboard:3001 +ADMIN_INTERNAL_URL=http://admin:3002 +API_URL=https://api.rentaldrivego.ma +NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1 + +# Frontend public URLs +SITE_ORIGIN=https://rentaldrivego.ma +NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma +NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma +NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma +NEXT_PUBLIC_STOREFRONT_URL=https://rentaldrivego.ma/storefront +NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard +NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma +DASHBOARD_URL=https://rentaldrivego.ma/dashboard +ADMIN_URL=https://rentaldrivego.ma/admin + +# CORS +CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma + +# Auth +JWT_SECRET=placeholder +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +NODE_ENV=production + +# File storage +FILE_STORAGE_ROOT=/var/lib/rentaldrivego/storage + +# PgManage +PGMANAGE_DEFAULT_USERNAME=admin +PGMANAGE_DEFAULT_PASSWORD=change-me + +# Email +EMAIL_FROM=rentaldrivego@gmail.com +EMAIL_FROM_NAME=RentalDriveGo +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_SCHEME=smtp +MAIL_USERNAME=rentaldrivego@gmail.com +MAIL_PASSWORD=placeholder +MAIL_FROM_ADDRESS=rentaldrivego@gmail.com +MAIL_FROM_NAME=RentalDriveGo +MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com +MAIL_REPLY_TO_NAME=RentalDriveGo diff --git a/production/README.md b/production/README.md new file mode 100644 index 0000000..a1f1b13 --- /dev/null +++ b/production/README.md @@ -0,0 +1,131 @@ +# RentalDriveGo Production Bundle + +This folder contains the files needed to run a production deployment from prebuilt Docker images. + +It is intended for a VPS/deploy directory such as `/opt/rentaldrivego` or `~/prodfolder`. +Do not run `docker compose up --build` from this folder; building from source requires the full repository. + +## Files + +- `docker-compose.production.yml` - production app stack. +- `traefik.yaml` - Traefik reverse proxy stack. +- `.env.docker.production.example` - sanitized environment template. +- `scripts/` - production deploy, startup, backup, and restore helpers. +- `docker/pgmanage/override.py` - PgManage runtime override. +- `dynamic/` - generated Traefik dynamic config location. + +## First-Time Setup + +```bash +cd /opt/rentaldrivego +chmod +x scripts/*.sh +``` + +This bundle includes `.env.docker.production` for the current `rentaldrivego.ma` deployment. Review it before starting services: + +```bash +nano .env.docker.production +``` + +At minimum, verify or rotate: + +- `POSTGRES_PASSWORD` +- `REDIS_PASSWORD` +- `JWT_SECRET` +- `IMAGE_TAG` +- `APP_IMAGE` +- `MAIL_PASSWORD` +- `PGMANAGE_DEFAULT_PASSWORD` + +If Docker needs to log in before pulling the app image, also set: + +- `REGISTRY_USER` +- `REGISTRY_PASSWORD` + +For the current VPN-reachable image registry, use: + +```env +APP_IMAGE=10.0.0.4/melabidi/carmanagement +REGISTRY_HOST=10.0.0.4 +``` + +## VPN Container Image Handoff + +If the registry is only reachable inside a VPN container, use the shared archive folder: + +```env +IMAGE_ARCHIVE_DIR=/opt/docker-image-transfer +IMAGE_ARCHIVE_REQUIRED=true +APP_IMAGE=10.0.0.4/melabidi/carmanagement +IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0 +REGISTRY_USER=melabidi +``` + +From the VPS, use the VPN container network to copy the image into the transfer folder: + +```bash +VPN_CONTAINER="6c5dca44e1c2" ./fetch-image-through-vpn.sh +``` + +Equivalent manual command: + +```bash +sudo mkdir -p /opt/docker-image-transfer +sudo chmod 700 /opt/docker-image-transfer + +IMAGE_TAG="13d0512048d86e9fe93e9395459d04663c2b7eb0" +REGISTRY_USER="melabidi" +VPN_CONTAINER="6c5dca44e1c2" + +read -rsp "Registry token: " REGISTRY_TOKEN +echo + +docker run --rm \ + --network "container:${VPN_CONTAINER}" \ + -v /opt/docker-image-transfer:/transfer \ + quay.io/skopeo/stable:latest \ + copy \ + --override-os linux \ + --override-arch amd64 \ + --src-tls-verify=false \ + --src-creds "${REGISTRY_USER}:${REGISTRY_TOKEN}" \ + "docker://10.0.0.4/melabidi/carmanagement:${IMAGE_TAG}" \ + "docker-archive:/transfer/carmanagement-${IMAGE_TAG}.tar:10.0.0.4/melabidi/carmanagement:${IMAGE_TAG}" +``` + +Set the same `IMAGE_TAG` in `.env.docker.production`, then run the deployment. The deploy script will run `docker load -i /opt/docker-image-transfer/carmanagement-${IMAGE_TAG}.tar` before starting services. Because `IMAGE_ARCHIVE_REQUIRED=true`, the VPS will not try to pull from `10.0.0.4` directly. + +## Deploy A Prebuilt Release + +```bash +cd /opt/rentaldrivego +chmod +x deploy-production.sh scripts/*.sh +./deploy-production.sh +``` + +The deploy script loads the shared-folder image archive, starts Traefik and shared services, runs migrations, then starts the app services. + +## Manual Compose Flow + +Use this only when you want to run compose directly: + +```bash +cd /opt/rentaldrivego +docker load -i /opt/docker-image-transfer/carmanagement-latest.tar +APP_ENV_FILE=.env.docker.production docker compose --env-file .env.docker.production -f docker-compose.production.yml up -d postgres redis +APP_ENV_FILE=.env.docker.production docker compose --env-file .env.docker.production -f docker-compose.production.yml run --rm migrate +APP_ENV_FILE=.env.docker.production docker compose --env-file .env.docker.production -f docker-compose.production.yml up -d api storefront dashboard admin +``` + +## Source Builds + +If you need to build on the VPS, copy the full repository instead of only this folder. `Dockerfile.production` requires: + +- `package.json` +- `package-lock.json` +- `turbo.json` +- `tsconfig.base.json` +- `apps/` +- `packages/` +- `config/` +- `docker/entrypoint.production.sh` diff --git a/production/deploy-production.sh b/production/deploy-production.sh new file mode 100755 index 0000000..13b7495 --- /dev/null +++ b/production/deploy-production.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${ROOT_DIR}" + +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +usage() { + cat <<'EOF' +Usage: + ./deploy-production.sh [--with-pgmanage] [--skip-prune] + +What it does: + 1. Validates .env.docker.production + 2. Loads IMAGE_TAG, APP_IMAGE, registry, and archive settings from the env file + 3. Loads a matching Docker image archive from IMAGE_ARCHIVE_DIR when present + 4. Otherwise logs in to the registry if credentials are configured, then pulls + 5. Reuses healthy Traefik, Postgres, and Redis containers when present + 6. Waits for health checks and prints container status + +VPN/shared-folder image flow: + Put an archive matching IMAGE_TAG in ./image-archives before running this script. + Example: ./image-archives/carmanagement-latest.tar +EOF +} + +include_pgmanage=0 +skip_prune=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --with-pgmanage) + include_pgmanage=1 + shift + ;; + --skip-prune) + skip_prune=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Missing required command: ${command_name}" >&2 + exit 1 + fi +} + +normalize_archive_dir() { + if [[ -z "${IMAGE_ARCHIVE_DIR:-}" ]]; then + return 0 + fi + + case "${IMAGE_ARCHIVE_DIR}" in + /*) + ;; + *) + IMAGE_ARCHIVE_DIR="${ROOT_DIR}/${IMAGE_ARCHIVE_DIR#./}" + export IMAGE_ARCHIVE_DIR + ;; + esac +} + +print_release_summary() { + echo "Production deployment" + echo " Image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}" + echo " Env: ${ENV_FILE}" + echo " Compose project: ${PROJECT_NAME}" + + if [[ -n "${IMAGE_ARCHIVE_DIR:-}" ]]; then + echo " Image archive dir: ${IMAGE_ARCHIVE_DIR}" + fi +} + +container_status() { + local container_name="$1" + + docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_name}" 2>/dev/null || echo "missing" +} + +compose_service_status() { + local compose_name="$1" + local service_name="$2" + local container_id + + case "${compose_name}" in + prod) + container_id="$(prod_compose ps -q "${service_name}" 2>/dev/null || true)" + ;; + traefik) + container_id="$(traefik_compose ps -q "${service_name}" 2>/dev/null || true)" + ;; + *) + echo "missing" + return 0 + ;; + esac + + if [[ -z "${container_id}" ]]; then + echo "missing" + return 0 + fi + + docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_id}" 2>/dev/null || echo "missing" +} + +first_container_id() { + docker ps -q "$@" 2>/dev/null | sed -n '1p' +} + +ensure_container_on_traefik_network() { + local container_id="$1" + local networks + + networks="$(docker inspect --format='{{range $name, $_ := .NetworkSettings.Networks}}{{printf "%s " $name}}{{end}}' "${container_id}" 2>/dev/null || true)" + case " ${networks} " in + *" ${TRAEFIK_NETWORK} "*) + ;; + *) + echo "Connecting existing Traefik container ${container_id} to ${TRAEFIK_NETWORK}." + docker network connect "${TRAEFIK_NETWORK}" "${container_id}" 2>/dev/null || true + ;; + esac +} + +find_running_traefik_container() { + local container_id + + container_id="$(first_container_id --filter name=traefik)" + if [[ -n "${container_id}" ]]; then + printf '%s' "${container_id}" + return 0 + fi + + container_id="$(first_container_id --filter ancestor=traefik --filter status=running)" + if [[ -n "${container_id}" ]]; then + printf '%s' "${container_id}" + return 0 + fi + + container_id="$(first_container_id --filter ancestor=traefik:latest --filter status=running)" + if [[ -n "${container_id}" ]]; then + printf '%s' "${container_id}" + return 0 + fi +} + +ensure_ports_available_for_traefik() { + local container_id + local port_listener + + container_id="$(first_container_id --filter publish=80 --filter status=running)" + if [[ -n "${container_id}" ]]; then + echo "Port 80 is already used by container ${container_id}, and it does not look like Traefik." >&2 + echo "Stop that container or set up Traefik outside this deployment before rerunning." >&2 + exit 1 + fi + + port_listener="$(sudo ss -H -ltnp 'sport = :80' 2>/dev/null || ss -H -ltnp 'sport = :80' 2>/dev/null || true)" + if [[ -n "${port_listener}" ]]; then + echo "Port 80 is already used by a host process:" >&2 + echo "${port_listener}" >&2 + echo "Stop that process before starting Traefik, or manage Traefik outside this deployment." >&2 + exit 1 + fi + + container_id="$(first_container_id --filter publish=443 --filter status=running)" + if [[ -n "${container_id}" ]]; then + echo "Port 443 is already used by container ${container_id}, and it does not look like Traefik." >&2 + echo "Stop that container or set up Traefik outside this deployment before rerunning." >&2 + exit 1 + fi + + port_listener="$(sudo ss -H -ltnp 'sport = :443' 2>/dev/null || ss -H -ltnp 'sport = :443' 2>/dev/null || true)" + if [[ -n "${port_listener}" ]]; then + echo "Port 443 is already used by a host process:" >&2 + echo "${port_listener}" >&2 + echo "Stop that process before starting Traefik, or manage Traefik outside this deployment." >&2 + exit 1 + fi +} + +ensure_traefik_ready() { + local existing_container_id + local status + + status="$(compose_service_status traefik traefik)" + case "${status}" in + running|healthy) + echo "Traefik is already ${status}; reusing existing container." + ;; + missing|exited|dead|created|restarting|removing|paused|unhealthy) + existing_container_id="$(find_running_traefik_container)" + if [[ -n "${existing_container_id}" ]]; then + echo "Found existing Traefik container ${existing_container_id}; reusing it." + ensure_container_on_traefik_network "${existing_container_id}" + return 0 + fi + ensure_ports_available_for_traefik + echo "Starting Traefik (status: ${status})" + start_traefik + ;; + *) + existing_container_id="$(find_running_traefik_container)" + if [[ -n "${existing_container_id}" ]]; then + echo "Found existing Traefik container ${existing_container_id}; reusing it." + ensure_container_on_traefik_network "${existing_container_id}" + return 0 + fi + ensure_ports_available_for_traefik + echo "Starting Traefik (status: ${status})" + start_traefik + ;; + esac +} + +ensure_postgres_ready() { + local status + + status="$(compose_service_status prod postgres)" + case "${status}" in + healthy) + echo "Postgres is already healthy; reusing existing container." + ;; + running) + echo "Postgres is running; waiting for health check." + wait_for_healthy postgres 120 + ;; + missing|exited|dead|created|restarting|removing|paused|unhealthy) + echo "Starting Postgres (status: ${status})" + prod_compose up -d postgres + wait_for_healthy postgres 120 + ;; + *) + echo "Starting Postgres (status: ${status})" + prod_compose up -d postgres + wait_for_healthy postgres 120 + ;; + esac +} + +ensure_redis_ready() { + local status + + status="$(compose_service_status prod redis)" + case "${status}" in + healthy) + echo "Redis is already healthy; reusing existing container." + ;; + running) + echo "Redis is running; waiting for health check." + wait_for_healthy redis 120 + ;; + missing|exited|dead|created|restarting|removing|paused|unhealthy) + echo "Starting Redis (status: ${status})" + prod_compose up -d redis + wait_for_healthy redis 120 + ;; + *) + echo "Starting Redis (status: ${status})" + prod_compose up -d redis + wait_for_healthy redis 120 + ;; + esac +} + +require_command docker + +ensure_env_file +load_prod_env_exports +require_release_image +normalize_archive_dir +print_release_summary + +ensure_traefik_network +ensure_api_uploads_volume +login_registry_if_configured + +echo "Preparing release image" +pull_prod_release_images + +echo "Checking shared infrastructure" +ensure_traefik_ready +ensure_postgres_ready +ensure_redis_ready + +echo "Running database migrations" +run_prod_migrations + +echo "Starting application services" +app_services=(api storefront dashboard admin) + +if [[ "${include_pgmanage}" -eq 1 ]]; then + prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage +else + prod_compose up -d "${app_services[@]}" +fi + +wait_for_healthy api 180 +wait_for_healthy storefront 180 +wait_for_healthy dashboard 180 +wait_for_healthy admin 180 + +if [[ "${skip_prune}" -eq 0 ]]; then + echo "Pruning dangling images" + docker image prune -f >/dev/null +fi + +echo "Final container status" +prod_compose ps + +echo "Production release ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} is up and healthy." diff --git a/production/docker-compose.production.yml b/production/docker-compose.production.yml new file mode 100644 index 0000000..ee5f7a7 --- /dev/null +++ b/production/docker-compose.production.yml @@ -0,0 +1,347 @@ +name: rentaldrivego-prod + +x-node-runtime-hardening: &node-runtime-hardening + init: true + user: "10001:10001" + entrypoint: [] + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=64m,mode=1777 + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + networks: + - internal + environment: + POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres_prod_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - internal + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set} + command: > + sh -ec 'exec redis-server --appendonly yes --requirepass "$${REDIS_PASSWORD}" --maxmemory "$${REDIS_MAXMEMORY:-256mb}" --maxmemory-policy allkeys-lru' + healthcheck: + test: ["CMD-SHELL", 'redis-cli -a "$${REDIS_PASSWORD}" ping | grep PONG'] + interval: 10s + timeout: 5s + retries: 10 + volumes: + - redis_prod_data:/data + + migrate: + <<: *node-runtime-hardening + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile.production + args: + API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:4000/api/v1} + DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} + ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} + NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} + command: ["npm", "run", "db:deploy"] + networks: + - internal + depends_on: + postgres: + condition: service_healthy + env_file: + - ${APP_ENV_FILE:-.env.production} + environment: + DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true} + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + restart: "no" + + api: + <<: *node-runtime-hardening + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile.production + args: + API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:4000/api/v1} + DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} + ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} + NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 60s + command: ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] + networks: + - internal + - traefik-proxy + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - ${APP_ENV_FILE:-.env.production} + environment: + DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true} + FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + volumes: + - api_uploads:/var/lib/rentaldrivego/storage + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`${API_DOMAIN}`) && !HeadersRegexp(`x-middleware-subrequest`, `.+`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls=true + - traefik.http.routers.api.tls.certresolver=letsencrypt + - traefik.http.services.api.loadbalancer.server.port=4000 + - traefik.http.routers.api.middlewares=rdg-security-headers@docker + - traefik.http.middlewares.rdg-security-headers.headers.stsSeconds=31536000 + - traefik.http.middlewares.rdg-security-headers.headers.stsIncludeSubdomains=true + - traefik.http.middlewares.rdg-security-headers.headers.contentTypeNosniff=true + - traefik.http.middlewares.rdg-security-headers.headers.frameDeny=true + - traefik.http.middlewares.rdg-security-headers.headers.referrerPolicy=strict-origin-when-cross-origin + - traefik.http.middlewares.rdg-security-headers.headers.permissionsPolicy=camera=(), microphone=(), geolocation=() + + homepage: + <<: *node-runtime-hardening + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile.production + args: + API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:4000/api/v1} + DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} + ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} + NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 60s + command: ["npm", "run", "start", "--workspace", "@rentaldrivego/homepage"] + networks: + - internal + - traefik-proxy + depends_on: + api: + condition: service_healthy + env_file: + - ${APP_ENV_FILE:-.env.production} + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.homepage.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && !HeadersRegexp(`x-middleware-subrequest`, `.+`) + - traefik.http.routers.homepage.entrypoints=websecure + - traefik.http.routers.homepage.tls=true + - traefik.http.routers.homepage.tls.certresolver=letsencrypt + - traefik.http.routers.homepage.service=homepage-svc + - traefik.http.routers.homepage.priority=10 + - traefik.http.services.homepage-svc.loadbalancer.server.port=3000 + - traefik.http.routers.homepage.middlewares=rdg-security-headers@docker + + storefront: + <<: *node-runtime-hardening + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile.production + args: + API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:4000/api/v1} + DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} + ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} + NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3004/', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 60s + command: ["npm", "run", "start", "--workspace", "@rentaldrivego/storefront"] + networks: + - internal + - traefik-proxy + depends_on: + api: + condition: service_healthy + env_file: + - ${APP_ENV_FILE:-.env.production} + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.storefront.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/explore`, `/renter`, `/sign-in`) + - traefik.http.routers.storefront.entrypoints=websecure + - traefik.http.routers.storefront.tls=true + - traefik.http.routers.storefront.tls.certresolver=letsencrypt + - traefik.http.routers.storefront.service=storefront-svc + - traefik.http.routers.storefront.priority=20 + - traefik.http.services.storefront-svc.loadbalancer.server.port=3004 + - traefik.http.routers.storefront.middlewares=rdg-security-headers@docker + + dashboard: + <<: *node-runtime-hardening + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile.production + args: + API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:4000/api/v1} + DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} + ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} + NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/dashboard', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 60s + command: ["npm", "run", "start", "--workspace", "@rentaldrivego/dashboard"] + networks: + - internal + - traefik-proxy + depends_on: + api: + condition: service_healthy + env_file: + - ${APP_ENV_FILE:-.env.production} + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.dashboard.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/dashboard`) && !HeadersRegexp(`x-middleware-subrequest`, `.+`) + - traefik.http.routers.dashboard.entrypoints=websecure + - traefik.http.routers.dashboard.tls=true + - traefik.http.routers.dashboard.tls.certresolver=letsencrypt + - traefik.http.routers.dashboard.service=dashboard-svc + - traefik.http.routers.dashboard.priority=20 + - traefik.http.services.dashboard-svc.loadbalancer.server.port=3001 + - traefik.http.routers.dashboard.middlewares=rdg-security-headers@docker + + admin: + <<: *node-runtime-hardening + image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile.production + args: + API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:4000/api/v1} + DASHBOARD_INTERNAL_URL: ${DASHBOARD_INTERNAL_URL:-http://dashboard:3001} + ADMIN_INTERNAL_URL: ${ADMIN_INTERNAL_URL:-http://admin:3002} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL} + NEXT_PUBLIC_HOMEPAGE_URL: ${NEXT_PUBLIC_HOMEPAGE_URL} + NEXT_PUBLIC_STOREFRONT_URL: ${NEXT_PUBLIC_STOREFRONT_URL} + NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL} + SITE_ORIGIN: ${SITE_ORIGIN} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/admin', (r) => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 60s + command: ["npm", "run", "start", "--workspace", "@rentaldrivego/admin"] + networks: + - internal + - traefik-proxy + depends_on: + api: + condition: service_healthy + env_file: + - ${APP_ENV_FILE:-.env.production} + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.admin.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/admin`) && !HeadersRegexp(`x-middleware-subrequest`, `.+`) + - traefik.http.routers.admin.entrypoints=websecure + - traefik.http.routers.admin.tls=true + - traefik.http.routers.admin.tls.certresolver=letsencrypt + - traefik.http.routers.admin.service=admin-svc + - traefik.http.routers.admin.priority=20 + - traefik.http.services.admin-svc.loadbalancer.server.port=3002 + - traefik.http.routers.admin.middlewares=rdg-security-headers@docker + + pgmanage: + image: cmdpromptinc/pgmanage-enterprise:latest + profiles: + - private-tools + restart: unless-stopped + networks: + - internal + depends_on: + postgres: + condition: service_healthy + environment: + PGMANAGE_LISTEN_PORT: "8000" + PGMANAGE_DEFAULT_USERNAME: ${PGMANAGE_DEFAULT_USERNAME:-} + PGMANAGE_DEFAULT_PASSWORD: ${PGMANAGE_DEFAULT_PASSWORD:-} + PGMANAGE_LICENSE_KEY: ${PGMANAGE_LICENSE_KEY:-} + PGMANAGE_SECURE_COOKIES: ${PGMANAGE_SECURE_COOKIES:-True} + volumes: + - pgmanage_prod_data:/appdata + - ./docker/pgmanage/override.py:/appdata/override.py:ro + +networks: + internal: + driver: bridge + traefik-proxy: + external: true + +volumes: + postgres_prod_data: + redis_prod_data: + pgmanage_prod_data: + api_uploads: + external: true + name: ${DOCKER_PROD_API_UPLOADS_VOLUME:-rentaldrivego-prod_api_uploads} diff --git a/production/docker/pgmanage/override.py b/production/docker/pgmanage/override.py new file mode 100644 index 0000000..50f6d2f --- /dev/null +++ b/production/docker/pgmanage/override.py @@ -0,0 +1,6 @@ +CSRF_TRUSTED_ORIGINS = ["https://pgmanage.rentaldrivego.ma"] +ALLOWED_HOSTS = ["pgmanage.rentaldrivego.ma"] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True diff --git a/production/dynamic/.gitkeep b/production/dynamic/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/production/dynamic/.gitkeep @@ -0,0 +1 @@ + diff --git a/production/fetch-image-through-vpn.sh b/production/fetch-image-through-vpn.sh new file mode 100755 index 0000000..6c1c0c2 --- /dev/null +++ b/production/fetch-image-through-vpn.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="${IMAGE_TAG:-${1:-13d0512048d86e9fe93e9395459d04663c2b7eb0}}" +APP_IMAGE="${APP_IMAGE:-10.0.0.4/melabidi/carmanagement}" +REGISTRY_USER="${REGISTRY_USER:-melabidi}" +REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" +VPN_CONTAINER="${VPN_CONTAINER:-}" +TRANSFER_DIR="${TRANSFER_DIR:-/opt/docker-image-transfer}" + +usage() { + cat <<'EOF' +Usage: + VPN_CONTAINER= ./fetch-image-through-vpn.sh [image-tag] + +Optional env vars: + APP_IMAGE Default: 10.0.0.4/melabidi/carmanagement + IMAGE_TAG Default: 13d0512048d86e9fe93e9395459d04663c2b7eb0 + REGISTRY_USER Default: melabidi + REGISTRY_TOKEN Prompted when unset + TRANSFER_DIR Default: /opt/docker-image-transfer + +The script uses skopeo inside the VPN container network and writes: + ${TRANSFER_DIR}/carmanagement-${IMAGE_TAG}.tar +EOF +} + +if [[ -z "${IMAGE_TAG}" ]]; then + usage >&2 + exit 1 +fi + +if [[ -z "${VPN_CONTAINER}" ]]; then + echo "VPN_CONTAINER is required." >&2 + usage >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Missing required command: docker" >&2 + exit 1 +fi + +archive_name="carmanagement-${IMAGE_TAG}.tar" +archive_path="${TRANSFER_DIR}/${archive_name}" + +sudo mkdir -p "${TRANSFER_DIR}" +sudo chmod 700 "${TRANSFER_DIR}" +sudo rm -f "${archive_path}" + +if [[ -z "${REGISTRY_TOKEN}" ]]; then + read -rsp "Registry token: " REGISTRY_TOKEN + echo +fi + +docker run --rm \ + --network "container:${VPN_CONTAINER}" \ + -v "${TRANSFER_DIR}:/transfer" \ + quay.io/skopeo/stable:latest \ + copy \ + --override-os linux \ + --override-arch amd64 \ + --src-tls-verify=false \ + --src-creds "${REGISTRY_USER}:${REGISTRY_TOKEN}" \ + "docker://${APP_IMAGE}:${IMAGE_TAG}" \ + "docker-archive:/transfer/${archive_name}:${APP_IMAGE}:${IMAGE_TAG}" + +echo "Image archive ready: ${TRANSFER_DIR}/${archive_name}" diff --git a/production/image-archives/.gitkeep b/production/image-archives/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/production/image-archives/.gitkeep @@ -0,0 +1 @@ + diff --git a/production/scripts/backup-restore-guide.md b/production/scripts/backup-restore-guide.md new file mode 100644 index 0000000..5400ae3 --- /dev/null +++ b/production/scripts/backup-restore-guide.md @@ -0,0 +1,111 @@ +# Backup & Restore Guide + +## What gets backed up + +| File | Contents | +|------|----------| +| `postgres.dump` | Full PostgreSQL database (pg_dump custom format) | +| `api-uploads.tar.gz` | User-uploaded files (vehicle images, documents, etc.) | +| `traefik-letsencrypt.tar.gz` | Let's Encrypt SSL certificates | +| `volumes/` | Raw Docker volume archives (Redis, pgmanage, etc.) | +| `manifest.txt` | Backup metadata (timestamp, project name) | + +> **Note:** The `.env.docker.production` file is NOT included in backups. Store it separately in a secure location (password manager, encrypted storage). You will need it to restore on a new server. + +--- + +## Running a Backup + +```bash +cd ~/car_management_system + +# Default — saves to ./backups// +bash scripts/docker-prod-backup.sh + +# Custom backup directory +bash scripts/docker-prod-backup.sh /mnt/external/backups +``` + +The script will create a timestamped directory, e.g.: +``` +backups/rentaldrivego-prod-20260522T103000Z/ +``` + +PostgreSQL does not need to be stopped. The script brings it up automatically if needed. + +--- + +## Running a Restore + +> **Warning:** Restore is destructive. It will overwrite the current database and uploaded files. + +```bash +cd ~/car_management_system + +bash scripts/docker-prod-restore.sh backups/rentaldrivego-prod- --yes +``` + +The `--yes` flag is required. Without it, the script exits with instructions. + +### What the restore does (in order) + +1. Stops all app services (api, dashboard, storefront, admin, pgmanage, redis, traefik) +2. Starts PostgreSQL +3. Restores the database with `pg_restore --clean --if-exists` +4. Restores uploaded files into the api_uploads volume +5. Restores Traefik SSL certificates +6. Restores remaining Docker volumes (Redis, pgmanage) +7. Starts all services back up + +--- + +## Automated Daily Backups (cron) + +To schedule a daily backup at 3:00 AM: + +```bash +(crontab -l 2>/dev/null; echo '0 3 * * * cd /root/car_management_system && bash scripts/docker-prod-backup.sh /root/car_management_system/backups >> /var/log/rentaldrivego-backup.log 2>&1') | crontab - +``` + +Verify the cron job was added: +```bash +crontab -l +``` + +### Cleaning up old backups + +To keep only the last 7 days of backups, add a cleanup job: + +```bash +(crontab -l 2>/dev/null; echo '30 3 * * * find /root/car_management_system/backups -maxdepth 1 -name "rentaldrivego-prod-*" -mtime +7 -exec rm -rf {} +') | crontab - +``` + +--- + +## Restoring on a Fresh Server + +1. Install Docker and Docker Compose on the new server +2. Clone the repository +3. Recreate `.env.docker.production` (from your secure backup of that file) +4. Copy the backup directory to the new server +5. Run the restore script: + ```bash + bash scripts/docker-prod-restore.sh /path/to/backup --yes + ``` + +--- + +## Verifying a Backup + +Check the backup directory contents and sizes: + +```bash +ls -lh backups/rentaldrivego-prod-/ +cat backups/rentaldrivego-prod-/manifest.txt +``` + +Test that the database dump is valid: + +```bash +pg_restore --list backups/rentaldrivego-prod-/postgres.dump | head -20 +``` diff --git a/production/scripts/cronjobs.md b/production/scripts/cronjobs.md new file mode 100644 index 0000000..6f4d968 --- /dev/null +++ b/production/scripts/cronjobs.md @@ -0,0 +1,6 @@ +root@rentaldrivego:~# command -v docker && (crontab -l 2>/dev/null | grep -v 'docker image prune -a -f'; echo '0 2 * * * /usr/bin/docker image prune -a -f >> /var/log/docker-image-prune.log 2>&1') | crontab - && crontab -l | tail -n 5 +/usr/bin/docker +0 2 * * * /usr/bin/docker image prune -a -f >> /var/log/docker-image-prune.log 2>&1 + +#how to run scripts +bash scripts/docker-prod-up-frontends.sh diff --git a/production/scripts/docker-prod-backup.sh b/production/scripts/docker-prod-backup.sh new file mode 100755 index 0000000..91cff45 --- /dev/null +++ b/production/scripts/docker-prod-backup.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +BACKUP_ROOT="${1:-${ROOT_DIR}/backups}" +shift || true +EXTRA_VOLUMES=("$@") +TIMESTAMP="$(date -u +"%Y%m%dT%H%M%SZ")" +mkdir -p "${BACKUP_ROOT}" +BACKUP_ROOT="$(cd "${BACKUP_ROOT}" && pwd)" +BACKUP_DIR="${BACKUP_ROOT}/rentaldrivego-prod-${TIMESTAMP}" +VOLUME_BACKUP_DIR="${BACKUP_DIR}/volumes" + +DEFAULT_VOLUMES=( + "${PROJECT_NAME}_api_uploads" + "${PROJECT_NAME}_pgmanage_prod_data" + "${PROJECT_NAME}_postgres_prod_data" + "${PROJECT_NAME}_redis_prod_data" +) + +mkdir -p "${BACKUP_DIR}" +mkdir -p "${VOLUME_BACKUP_DIR}" + +ensure_env_file +ensure_traefik_network + +echo "Creating production backup in ${BACKUP_DIR}" + +prod_compose up -d postgres >/dev/null + +echo "Backing up PostgreSQL database..." +prod_compose exec -T postgres sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "${BACKUP_DIR}/postgres.dump" + +echo "Backing up uploaded files..." +prod_compose run --rm --no-deps api bash -lc ' + mkdir -p /var/lib/rentaldrivego/storage + tar -czf - -C /var/lib/rentaldrivego/storage . +' > "${BACKUP_DIR}/api-uploads.tar.gz" + +echo "Backing up Traefik certificates..." +if traefik_compose run --rm traefik sh -lc 'test -d /letsencrypt' > /dev/null 2>&1; then + traefik_compose run --rm traefik sh -lc ' + mkdir -p /letsencrypt + tar -czf - -C /letsencrypt . + ' > "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" +else + echo "Skipping Traefik certificate backup; Traefik runtime is not available." +fi + +declare -a ALL_VOLUMES=("${DEFAULT_VOLUMES[@]}") + +if [[ -n "${DOCKER_EXTRA_BACKUP_VOLUMES:-}" ]]; then + # shellcheck disable=SC2206 + ALL_VOLUMES+=(${DOCKER_EXTRA_BACKUP_VOLUMES}) +fi + +if [[ "${#EXTRA_VOLUMES[@]}" -gt 0 ]]; then + ALL_VOLUMES+=("${EXTRA_VOLUMES[@]}") +fi + +echo "Backing up named Docker volumes..." +declare -A seen_volumes=() +for volume_name in "${ALL_VOLUMES[@]}"; do + [[ -n "${volume_name}" ]] || continue + if [[ -n "${seen_volumes[${volume_name}]:-}" ]]; then + continue + fi + seen_volumes["${volume_name}"]=1 + + if ! docker_volume_exists "${volume_name}"; then + echo "Skipping missing volume: ${volume_name}" + continue + fi + + echo " - ${volume_name}" + backup_named_volume "${volume_name}" "${VOLUME_BACKUP_DIR}/${volume_name}.tar.gz" +done + +cat > "${BACKUP_DIR}/manifest.txt" < /dev/null; then + echo " - ${VOLUME_BACKUP_DIR}/*.tar.gz" +fi +echo " - ${BACKUP_DIR}/manifest.txt" diff --git a/production/scripts/docker-prod-common.sh b/production/scripts/docker-prod-common.sh new file mode 100755 index 0000000..8474c95 --- /dev/null +++ b/production/scripts/docker-prod-common.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.production.yml" +TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml" +ENV_FILE="${ROOT_DIR}/.env.docker.production" +PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}" +TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}" +VOLUME_BACKUP_IMAGE="${DOCKER_VOLUME_BACKUP_IMAGE:-postgres:16-alpine}" +API_UPLOADS_VOLUME="${DOCKER_PROD_API_UPLOADS_VOLUME:-${PROJECT_NAME}_api_uploads}" + +ensure_env_file() { + if [[ ! -f "${ENV_FILE}" ]]; then + echo "Missing ${ENV_FILE}" >&2 + echo "Create it from .env.docker.production.example before starting production containers." >&2 + exit 1 + fi + + validate_prod_env_file +} + +read_env_value() { + local key="$1" + local value + + value="$( + awk -F= -v key="$key" ' + $1 == key { + value = substr($0, index($0, "=") + 1) + } + END { + if (value != "") { + print value + } + } + ' "${ENV_FILE}" + )" + + value="${value%$'\r'}" + value="${value#\"}" + value="${value%\"}" + printf '%s' "${value}" +} + +export_env_value_if_unset() { + local key="$1" + local value + + if [[ -n "${!key:-}" ]]; then + return 0 + fi + + value="$(read_env_value "${key}")" + if [[ -n "${value}" ]]; then + export "${key}=${value}" + fi +} + +load_prod_env_exports() { + local key + + for key in \ + APP_IMAGE \ + APP_VERSION \ + IMAGE_TAG \ + IMAGE_ARCHIVE_DIR \ + IMAGE_ARCHIVE_REQUIRED \ + REGISTRY_HOST \ + REGISTRY_USER \ + REGISTRY_PASSWORD + do + export_env_value_if_unset "${key}" + done +} + +validate_prod_env_file() { + local api_domain public_site_domain cors_origins postgres_password redis_password jwt_secret + + api_domain="$(read_env_value API_DOMAIN)" + public_site_domain="$(read_env_value PUBLIC_SITE_DOMAIN)" + cors_origins="$(read_env_value CORS_ORIGINS)" + postgres_password="$(read_env_value POSTGRES_PASSWORD)" + redis_password="$(read_env_value REDIS_PASSWORD)" + jwt_secret="$(read_env_value JWT_SECRET)" + + if [[ -z "${api_domain}" || "${api_domain}" == *example.com* ]]; then + echo "Invalid API_DOMAIN in ${ENV_FILE}. Set it to the real production API hostname, not example.com." >&2 + exit 1 + fi + + if [[ -z "${public_site_domain}" || "${public_site_domain}" == *example.com* ]]; then + echo "Invalid PUBLIC_SITE_DOMAIN in ${ENV_FILE}. Set it to the real production site hostname, not example.com." >&2 + exit 1 + fi + + if [[ -z "${cors_origins}" || "${cors_origins}" == *example.com* ]]; then + echo "Invalid CORS_ORIGINS in ${ENV_FILE}. Replace example.com origins with the real production domains." >&2 + exit 1 + fi + + for pair in "POSTGRES_PASSWORD:${postgres_password}" "REDIS_PASSWORD:${redis_password}" "JWT_SECRET:${jwt_secret}"; do + key="${pair%%:*}" + value="${pair#*:}" + if [[ -z "${value}" || "${value}" == replace-with-* || "${value}" == *changeme* || "${value}" == *change-me* ]]; then + echo "Invalid ${key} in ${ENV_FILE}. Set a real production secret." >&2 + exit 1 + fi + done + + +} + +ensure_traefik_network() { + if ! docker network inspect "${TRAEFIK_NETWORK}" >/dev/null 2>&1; then + echo "Creating Docker network: ${TRAEFIK_NETWORK}" + docker network create "${TRAEFIK_NETWORK}" >/dev/null + fi +} + +ensure_api_uploads_volume() { + if ! docker volume inspect "${API_UPLOADS_VOLUME}" >/dev/null 2>&1; then + echo "Creating Docker volume: ${API_UPLOADS_VOLUME}" + docker volume create "${API_UPLOADS_VOLUME}" >/dev/null + fi +} + +prod_compose() { + APP_ENV_FILE="${ENV_FILE}" DOCKER_PROD_API_UPLOADS_VOLUME="${API_UPLOADS_VOLUME}" docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@" +} + +traefik_compose() { + docker compose --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@" +} + +start_prod_services() { + ensure_env_file + ensure_api_uploads_volume + + local services=("$@") + local needs_migrations=0 + + for service in "${services[@]}"; do + case "${service}" in + api|storefront|dashboard|admin) + needs_migrations=1 + break + ;; + esac + done + + if [[ ${needs_migrations} -eq 1 ]]; then + prod_compose up --build -d postgres redis + wait_for_healthy postgres 120 + prod_compose run --rm --build migrate + fi + + prod_compose up --build -d "$@" +} + +require_release_image() { + IMAGE_TAG="${IMAGE_TAG:-${APP_VERSION:-}}" + export IMAGE_TAG + + if [[ -z "${IMAGE_TAG:-}" ]]; then + echo "IMAGE_TAG must be set for production deploys." >&2 + exit 1 + fi +} + +login_registry_if_configured() { + if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then + echo "IMAGE_ARCHIVE_REQUIRED is enabled; skipping docker login." + return 0 + fi + + if [[ -n "${IMAGE_ARCHIVE_DIR:-}" && -d "${IMAGE_ARCHIVE_DIR}" ]]; then + shopt -s nullglob + local archives=( + "${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tar + "${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tar.gz + "${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tgz + ) + shopt -u nullglob + + if [[ ${#archives[@]} -gt 0 && -z "${REGISTRY_USER:-}" && -z "${REGISTRY_PASSWORD:-}" ]]; then + echo "Found image archive in IMAGE_ARCHIVE_DIR; skipping registry login." + return 0 + fi + fi + + if [[ -z "${REGISTRY_USER:-}" && -z "${REGISTRY_PASSWORD:-}" ]]; then + if [[ -n "${REGISTRY_HOST:-}" ]]; then + echo "REGISTRY_HOST is set but no registry credentials are configured; skipping docker login." + fi + return 0 + fi + + if [[ -z "${REGISTRY_HOST:-}" || -z "${REGISTRY_USER:-}" || -z "${REGISTRY_PASSWORD:-}" ]]; then + echo "REGISTRY_HOST, REGISTRY_USER, and REGISTRY_PASSWORD are all required when registry credentials are configured." >&2 + exit 1 + fi + + echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin +} + +pull_prod_release_images() { + require_release_image + + if load_release_image_archives; then + return 0 + fi + + if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then + echo "IMAGE_ARCHIVE_REQUIRED is enabled, but no archive for IMAGE_TAG=${IMAGE_TAG} was found in ${IMAGE_ARCHIVE_DIR:-}." >&2 + echo "The VPS cannot reach the VPN-only registry. Create a shared archive first, for example:" >&2 + echo " docker save ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} -o ${IMAGE_ARCHIVE_DIR:-./image-archives}/carmanagement-${IMAGE_TAG}.tar" >&2 + exit 1 + fi + + prod_compose pull migrate api storefront dashboard admin +} + +load_release_image_archives() { + local archive_dir="${IMAGE_ARCHIVE_DIR:-}" + local archive + local loaded=0 + + if [[ -z "${archive_dir}" ]]; then + return 1 + fi + + if [[ ! -d "${archive_dir}" ]]; then + echo "IMAGE_ARCHIVE_DIR is set but does not exist: ${archive_dir}" >&2 + exit 1 + fi + + shopt -s nullglob + for archive in \ + "${archive_dir}"/*"${IMAGE_TAG}"*.tar \ + "${archive_dir}"/*"${IMAGE_TAG}"*.tar.gz \ + "${archive_dir}"/*"${IMAGE_TAG}"*.tgz + do + echo "Loading Docker image archive: ${archive}" + docker load -i "${archive}" + loaded=1 + done + shopt -u nullglob + + if [[ ${loaded} -eq 0 ]]; then + echo "No Docker image archive found for IMAGE_TAG=${IMAGE_TAG} in ${archive_dir}." + return 1 + fi + + echo "Loaded image archive(s) for ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}; skipping docker pull." + return 0 +} + +run_prod_migrations() { + prod_compose run --rm migrate +} + +wait_for_healthy() { + local service="$1" + local timeout="${2:-120}" + local container="${PROJECT_NAME}-${service}-1" + local elapsed=0 + + echo "Waiting for ${service} to be healthy (timeout: ${timeout}s)..." + while [[ $elapsed -lt $timeout ]]; do + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "missing") + case "$status" in + healthy) + echo "${service} is healthy." + return 0 + ;; + unhealthy) + echo "ERROR: ${service} is unhealthy. Check logs with: docker logs ${container}" >&2 + return 1 + ;; + esac + sleep 5 + elapsed=$((elapsed + 5)) + done + + echo "ERROR: Timed out waiting for ${service} to become healthy." >&2 + return 1 +} + +start_traefik() { + ensure_env_file + ensure_traefik_network + traefik_compose up -d +} + +stop_traefik() { + ensure_env_file + traefik_compose down >/dev/null 2>&1 || true +} + +docker_volume_exists() { + docker volume inspect "$1" >/dev/null 2>&1 +} + +backup_named_volume() { + local volume_name="$1" + local archive_path="$2" + + docker run --rm \ + -v "${volume_name}:/volume:ro" \ + -v "$(dirname "${archive_path}"):/backup" \ + "${VOLUME_BACKUP_IMAGE}" \ + sh -lc "tar -czf /backup/$(basename "${archive_path}") -C /volume ." +} + +volume_running_containers() { + docker ps --filter "volume=$1" --format '{{.Names}}' +} + +restore_named_volume() { + local volume_name="$1" + local archive_path="$2" + local running + + running="$(volume_running_containers "${volume_name}")" + if [[ -n "${running}" ]]; then + echo "Volume ${volume_name} is currently used by running containers:" >&2 + echo "${running}" >&2 + echo "Stop those containers before restoring this volume." >&2 + exit 1 + fi + + docker run --rm \ + -v "${volume_name}:/volume" \ + -v "$(dirname "${archive_path}"):/backup" \ + "${VOLUME_BACKUP_IMAGE}" \ + sh -lc "find /volume -mindepth 1 -delete && tar -xzf /backup/$(basename "${archive_path}") -C /volume" +} diff --git a/production/scripts/docker-prod-deploy.sh b/production/scripts/docker-prod-deploy.sh new file mode 100755 index 0000000..4e946b0 --- /dev/null +++ b/production/scripts/docker-prod-deploy.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +echo "Deploying production release ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-${APP_VERSION:-}}" + +ensure_env_file +load_prod_env_exports +ensure_traefik_network +ensure_api_uploads_volume +require_release_image +login_registry_if_configured + +echo "Pulling release images" +pull_prod_release_images + +echo "Starting shared infrastructure" +start_traefik +prod_compose up -d postgres redis +wait_for_healthy postgres 120 + +echo "Running database migrations" +run_prod_migrations + +echo "Starting application services" +prod_compose up -d api storefront dashboard admin +wait_for_healthy api 180 +wait_for_healthy storefront 180 +wait_for_healthy dashboard 180 +wait_for_healthy admin 180 + +echo "Pruning old images" +docker image prune -f >/dev/null + +echo "Production release ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} is up and healthy." diff --git a/production/scripts/docker-prod-restore.sh b/production/scripts/docker-prod-restore.sh new file mode 100755 index 0000000..f5b16f1 --- /dev/null +++ b/production/scripts/docker-prod-restore.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +BACKUP_DIR="${1:-}" +CONFIRM_FLAG="${2:-}" + +if [[ -z "${BACKUP_DIR}" ]]; then + echo "Usage: bash scripts/docker-prod-restore.sh --yes" >&2 + exit 1 +fi + +if [[ "${CONFIRM_FLAG}" != "--yes" ]]; then + echo "Restore is destructive and will overwrite the production database and uploaded files." >&2 + echo "Re-run with: bash scripts/docker-prod-restore.sh ${BACKUP_DIR} --yes" >&2 + exit 1 +fi + +if [[ ! -d "${BACKUP_DIR}" ]]; then + echo "Backup directory not found: ${BACKUP_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${BACKUP_DIR}/postgres.dump" ]]; then + echo "Missing database backup: ${BACKUP_DIR}/postgres.dump" >&2 + exit 1 +fi + +if [[ ! -f "${BACKUP_DIR}/api-uploads.tar.gz" ]]; then + echo "Missing uploads backup: ${BACKUP_DIR}/api-uploads.tar.gz" >&2 + exit 1 +fi + +ensure_env_file +ensure_traefik_network + +RAW_VOLUME_DIR="${BACKUP_DIR}/volumes" +SKIP_RAW_RESTORE_VOLUMES=( + "${PROJECT_NAME}_api_uploads" + "${PROJECT_NAME}_postgres_prod_data" +) + +echo "Stopping application services before restore..." +prod_compose stop api storefront dashboard admin pgmanage >/dev/null || true +prod_compose stop redis >/dev/null || true +stop_traefik + +echo "Ensuring PostgreSQL is running..." +prod_compose up -d postgres >/dev/null + +echo "Waiting for PostgreSQL..." +until prod_compose exec -T postgres sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; do + sleep 2 +done + +echo "Restoring PostgreSQL database..." +cat "${BACKUP_DIR}/postgres.dump" | prod_compose exec -T postgres sh -lc ' + pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges +' + +echo "Restoring uploaded files..." +cat "${BACKUP_DIR}/api-uploads.tar.gz" | prod_compose run --rm --no-deps api bash -lc ' + mkdir -p /var/lib/rentaldrivego/storage + find /var/lib/rentaldrivego/storage -mindepth 1 -delete + tar -xzf - -C /var/lib/rentaldrivego/storage +' + +if [[ -f "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" ]]; then + echo "Restoring Traefik certificates..." + cat "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" | traefik_compose run --rm traefik sh -lc ' + mkdir -p /letsencrypt + find /letsencrypt -mindepth 1 -delete + tar -xzf - -C /letsencrypt + chmod 600 /letsencrypt/acme.json 2>/dev/null || true + ' +fi + +if [[ -d "${RAW_VOLUME_DIR}" ]]; then + echo "Restoring raw Docker volume archives..." + for archive_path in "${RAW_VOLUME_DIR}"/*.tar.gz; do + [[ -e "${archive_path}" ]] || continue + volume_name="$(basename "${archive_path}" .tar.gz)" + + skip_volume=false + for managed_volume in "${SKIP_RAW_RESTORE_VOLUMES[@]}"; do + if [[ "${volume_name}" == "${managed_volume}" ]]; then + skip_volume=true + break + fi + done + + if [[ "${skip_volume}" == true ]]; then + echo "Skipping raw restore for ${volume_name}; handled by service-aware restore." + continue + fi + + if ! docker_volume_exists "${volume_name}"; then + echo "Creating missing volume ${volume_name}" + docker volume create "${volume_name}" >/dev/null + fi + + echo " - ${volume_name}" + restore_named_volume "${volume_name}" "${archive_path}" + done +fi + +echo "Starting production services..." +start_traefik +start_prod_services + +echo "Restore complete from ${BACKUP_DIR}" diff --git a/production/scripts/docker-prod-run-pulled-image.sh b/production/scripts/docker-prod-run-pulled-image.sh new file mode 100755 index 0000000..6a539dc --- /dev/null +++ b/production/scripts/docker-prod-run-pulled-image.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +APP_IMAGE="${APP_IMAGE:-registry.rentaldrivego.ma/rentaldrivego/car_management_system}" +APP_VERSION="${APP_VERSION:-${1:-}}" +REGISTRY_USER="${REGISTRY_USER:-rentaldrivego_registry}" +INCLUDE_PGMANAGE="${INCLUDE_PGMANAGE:-0}" +POSTGRES_CONTAINER="${PROJECT_NAME}-postgres-1" +REDIS_CONTAINER="${PROJECT_NAME}-redis-1" + +export APP_IMAGE APP_VERSION REGISTRY_USER + +require_release_image +ensure_env_file +ensure_api_uploads_volume + +container_health_status() { + local container_name="$1" + docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_name}" 2>/dev/null || echo "missing" +} + +ensure_postgres_ready() { + local status + status="$(container_health_status "${POSTGRES_CONTAINER}")" + + if [[ "${status}" == "healthy" ]]; then + echo "Postgres is already healthy; reusing existing database container." + return 0 + fi + + echo "Starting Postgres" + prod_compose up -d postgres + wait_for_healthy postgres 120 +} + +ensure_redis_ready() { + local status + status="$(container_health_status "${REDIS_CONTAINER}")" + + if [[ "${status}" == "running" || "${status}" == "healthy" ]]; then + echo "Redis is already running; leaving it in place." + return 0 + fi + + echo "Starting Redis" + prod_compose up -d redis +} + +prompt_registry_password_if_needed() { + if [[ -z "${REGISTRY_HOST:-}" ]]; then + return 0 + fi + + if [[ -n "${REGISTRY_PASSWORD:-}" ]]; then + return 0 + fi + + if [[ ! -t 0 ]]; then + echo "REGISTRY_PASSWORD is not set and no interactive terminal is available for a prompt." >&2 + exit 1 + fi + + printf 'Registry password for %s@%s: ' "${REGISTRY_USER}" "${REGISTRY_HOST}" >&2 + read -r -s REGISTRY_PASSWORD + printf '\n' >&2 + export REGISTRY_PASSWORD +} + +echo "Starting production services from pulled image ${APP_IMAGE}:${APP_VERSION}" + +prompt_registry_password_if_needed + +echo "Logging into registry if configured" +login_registry_if_configured + +echo "Pulling release images" +pull_prod_release_images + +ensure_postgres_ready +ensure_redis_ready + +echo "Migration status" +prod_compose run --rm --entrypoint sh migrate -lc 'npx prisma migrate status --schema packages/database/prisma/schema.prisma' + +echo "Applying migrations" +prod_compose run --rm migrate + +echo "Starting application services" +app_services=(api storefront dashboard admin) + +if [[ "${INCLUDE_PGMANAGE}" == "1" ]]; then + prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage +else + prod_compose up -d "${app_services[@]}" +fi +wait_for_healthy api 180 +wait_for_healthy storefront 180 +wait_for_healthy dashboard 180 +wait_for_healthy admin 180 + +echo "Production services are up from ${APP_IMAGE}:${APP_VERSION}" diff --git a/production/scripts/docker-prod-up-admin.sh b/production/scripts/docker-prod-up-admin.sh new file mode 100755 index 0000000..9aa2869 --- /dev/null +++ b/production/scripts/docker-prod-up-admin.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services admin +wait_for_healthy admin + +echo "Admin is up and healthy." diff --git a/production/scripts/docker-prod-up-all.sh b/production/scripts/docker-prod-up-all.sh new file mode 100755 index 0000000..8f875ae --- /dev/null +++ b/production/scripts/docker-prod-up-all.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_traefik +start_prod_services postgres redis api storefront dashboard admin +wait_for_healthy api +wait_for_healthy storefront +wait_for_healthy dashboard +wait_for_healthy admin + +echo "Production stack is up and healthy: traefik, postgres, redis, api, storefront, dashboard, admin" diff --git a/production/scripts/docker-prod-up-api.sh b/production/scripts/docker-prod-up-api.sh new file mode 100755 index 0000000..9169d51 --- /dev/null +++ b/production/scripts/docker-prod-up-api.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services api +wait_for_healthy api + +echo "API is up and healthy." diff --git a/production/scripts/docker-prod-up-dashboard.sh b/production/scripts/docker-prod-up-dashboard.sh new file mode 100755 index 0000000..eda063e --- /dev/null +++ b/production/scripts/docker-prod-up-dashboard.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services dashboard +wait_for_healthy dashboard + +echo "Dashboard is up and healthy." diff --git a/production/scripts/docker-prod-up-frontends.sh b/production/scripts/docker-prod-up-frontends.sh new file mode 100755 index 0000000..c996619 --- /dev/null +++ b/production/scripts/docker-prod-up-frontends.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services homepage storefront dashboard admin +wait_for_healthy homepage +wait_for_healthy storefront +wait_for_healthy dashboard +wait_for_healthy admin + +echo "All frontend services are up and healthy." diff --git a/production/scripts/docker-prod-up-homepage.sh b/production/scripts/docker-prod-up-homepage.sh new file mode 100755 index 0000000..454266f --- /dev/null +++ b/production/scripts/docker-prod-up-homepage.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services homepage +wait_for_healthy homepage + +echo "Homepage is up and healthy." diff --git a/production/scripts/docker-prod-up-pgmanage.sh b/production/scripts/docker-prod-up-pgmanage.sh new file mode 100755 index 0000000..c4dad62 --- /dev/null +++ b/production/scripts/docker-prod-up-pgmanage.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +ensure_env_file + +pgmanage_user="$(read_env_value PGMANAGE_DEFAULT_USERNAME)" +pgmanage_password="$(read_env_value PGMANAGE_DEFAULT_PASSWORD)" +if [[ -z "${pgmanage_user}" || -z "${pgmanage_password}" || "${pgmanage_user}" == "admin" || "${pgmanage_password}" == replace-with-* ]]; then + echo "Set strong PGMANAGE_DEFAULT_USERNAME and PGMANAGE_DEFAULT_PASSWORD before starting pgManage." >&2 + exit 1 +fi + +echo "Starting pgManage on the private Docker network only." +echo "Access it with an SSH tunnel or VPN path to the Docker host, not through public Traefik." +prod_compose --profile private-tools up --build -d pgmanage + +echo "pgManage is starting as a private tool." diff --git a/production/scripts/docker-prod-up-postgres.sh b/production/scripts/docker-prod-up-postgres.sh new file mode 100755 index 0000000..e83f540 --- /dev/null +++ b/production/scripts/docker-prod-up-postgres.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services postgres + +echo "Postgres is starting." diff --git a/production/scripts/docker-prod-up-redis.sh b/production/scripts/docker-prod-up-redis.sh new file mode 100755 index 0000000..fd6ebf3 --- /dev/null +++ b/production/scripts/docker-prod-up-redis.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services redis + +echo "Redis is starting." diff --git a/production/scripts/docker-prod-up-storefront.sh b/production/scripts/docker-prod-up-storefront.sh new file mode 100755 index 0000000..b582741 --- /dev/null +++ b/production/scripts/docker-prod-up-storefront.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_prod_services storefront +wait_for_healthy storefront + +echo "Storefront is up and healthy." diff --git a/production/scripts/docker-prod-up-traefik.sh b/production/scripts/docker-prod-up-traefik.sh new file mode 100755 index 0000000..61f8bef --- /dev/null +++ b/production/scripts/docker-prod-up-traefik.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/docker-prod-common.sh" + +start_traefik + +echo "Traefik is starting." diff --git a/production/traefik.yaml b/production/traefik.yaml new file mode 100644 index 0000000..e6fd4dc --- /dev/null +++ b/production/traefik.yaml @@ -0,0 +1,36 @@ +services: + traefik: + image: traefik:latest + command: + - --api.dashboard=false + - --api.insecure=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=traefik-proxy + - --providers.file.directory=/etc/traefik/dynamic + - --providers.file.watch=true + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.websecure.transport.respondingTimeouts.readTimeout=0 + - --entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0 + - --entrypoints.websecure.transport.respondingTimeouts.idleTimeout=1800s + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + restart: unless-stopped + ports: + - 80:80 + - 443:443 + volumes: + - traefik-letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./dynamic:/etc/traefik/dynamic:ro + networks: + - traefik-proxy +networks: + traefik-proxy: + external: true +volumes: + traefik-letsencrypt: diff --git a/scripts/docker-prod-common.sh b/scripts/docker-prod-common.sh index 5e9dae7..e061356 100644 --- a/scripts/docker-prod-common.sh +++ b/scripts/docker-prod-common.sh @@ -52,6 +52,37 @@ read_env_value() { printf '%s' "${value}" } +export_env_value_if_unset() { + local key="$1" + local value + + if [[ -n "${!key:-}" ]]; then + return 0 + fi + + value="$(read_env_value "${key}")" + if [[ -n "${value}" ]]; then + export "${key}=${value}" + fi +} + +load_prod_env_exports() { + local key + + for key in \ + APP_IMAGE \ + APP_VERSION \ + IMAGE_TAG \ + IMAGE_ARCHIVE_DIR \ + IMAGE_ARCHIVE_REQUIRED \ + REGISTRY_HOST \ + REGISTRY_USER \ + REGISTRY_PASSWORD + do + export_env_value_if_unset "${key}" + done +} + validate_prod_env_file() { local api_domain public_site_domain cors_origins portainer_domain postgres_password redis_password jwt_secret @@ -113,7 +144,7 @@ ensure_dynamic_dir() { } prod_compose() { - docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@" + APP_ENV_FILE="${ENV_FILE}" DOCKER_PROD_API_UPLOADS_VOLUME="${API_UPLOADS_VOLUME}" docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@" } portainer_compose() { @@ -154,25 +185,45 @@ start_prod_services() { } require_release_image() { - if [[ -z "${APP_IMAGE:-}" || -z "${APP_VERSION:-}" ]]; then - echo "APP_IMAGE and APP_VERSION must be set for pull-based production deploys." >&2 + IMAGE_TAG="${IMAGE_TAG:-${APP_VERSION:-}}" + export IMAGE_TAG + + if [[ -z "${IMAGE_TAG:-}" ]]; then + echo "IMAGE_TAG must be set for production deploys." >&2 exit 1 fi } login_registry_if_configured() { - local configured=0 - - [[ -n "${REGISTRY_HOST:-}" ]] && configured=$((configured + 1)) - [[ -n "${REGISTRY_USER:-}" ]] && configured=$((configured + 1)) - [[ -n "${REGISTRY_PASSWORD:-}" ]] && configured=$((configured + 1)) - - if [[ ${configured} -eq 0 ]]; then + if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then + echo "IMAGE_ARCHIVE_REQUIRED is enabled; skipping docker login." return 0 fi - if [[ ${configured} -ne 3 ]]; then - echo "REGISTRY_HOST, REGISTRY_USER, and REGISTRY_PASSWORD must either all be set or all be unset." >&2 + if [[ -n "${IMAGE_ARCHIVE_DIR:-}" && -d "${IMAGE_ARCHIVE_DIR}" ]]; then + shopt -s nullglob + local archives=( + "${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tar + "${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tar.gz + "${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tgz + ) + shopt -u nullglob + + if [[ ${#archives[@]} -gt 0 && -z "${REGISTRY_USER:-}" && -z "${REGISTRY_PASSWORD:-}" ]]; then + echo "Found image archive in IMAGE_ARCHIVE_DIR; skipping registry login." + return 0 + fi + fi + + if [[ -z "${REGISTRY_USER:-}" && -z "${REGISTRY_PASSWORD:-}" ]]; then + if [[ -n "${REGISTRY_HOST:-}" ]]; then + echo "REGISTRY_HOST is set but no registry credentials are configured; skipping docker login." + fi + return 0 + fi + + if [[ -z "${REGISTRY_HOST:-}" || -z "${REGISTRY_USER:-}" || -z "${REGISTRY_PASSWORD:-}" ]]; then + echo "REGISTRY_HOST, REGISTRY_USER, and REGISTRY_PASSWORD are all required when registry credentials are configured." >&2 exit 1 fi @@ -181,9 +232,56 @@ login_registry_if_configured() { pull_prod_release_images() { require_release_image + + if load_release_image_archives; then + return 0 + fi + + if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then + echo "IMAGE_ARCHIVE_REQUIRED is enabled, but no archive for IMAGE_TAG=${IMAGE_TAG} was found in ${IMAGE_ARCHIVE_DIR:-}." >&2 + echo "The VPS cannot reach the VPN-only registry. Create a shared archive first, for example:" >&2 + echo " docker save ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} -o ${IMAGE_ARCHIVE_DIR:-./image-archives}/carmanagement-${IMAGE_TAG}.tar" >&2 + exit 1 + fi + prod_compose pull migrate api storefront dashboard admin } +load_release_image_archives() { + local archive_dir="${IMAGE_ARCHIVE_DIR:-}" + local archive + local loaded=0 + + if [[ -z "${archive_dir}" ]]; then + return 1 + fi + + if [[ ! -d "${archive_dir}" ]]; then + echo "IMAGE_ARCHIVE_DIR is set but does not exist: ${archive_dir}" >&2 + exit 1 + fi + + shopt -s nullglob + for archive in \ + "${archive_dir}"/*"${IMAGE_TAG}"*.tar \ + "${archive_dir}"/*"${IMAGE_TAG}"*.tar.gz \ + "${archive_dir}"/*"${IMAGE_TAG}"*.tgz + do + echo "Loading Docker image archive: ${archive}" + docker load -i "${archive}" + loaded=1 + done + shopt -u nullglob + + if [[ ${loaded} -eq 0 ]]; then + echo "No Docker image archive found for IMAGE_TAG=${IMAGE_TAG} in ${archive_dir}." + return 1 + fi + + echo "Loaded image archive(s) for ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}; skipping docker pull." + return 0 +} + run_prod_migrations() { prod_compose run --rm migrate } diff --git a/scripts/docker-prod-deploy.sh b/scripts/docker-prod-deploy.sh index d410efc..4e946b0 100644 --- a/scripts/docker-prod-deploy.sh +++ b/scripts/docker-prod-deploy.sh @@ -5,9 +5,10 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${ROOT_DIR}/scripts/docker-prod-common.sh" -echo "Deploying production release ${APP_IMAGE:-}:${APP_VERSION:-}" +echo "Deploying production release ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG:-${APP_VERSION:-}}" ensure_env_file +load_prod_env_exports ensure_traefik_network ensure_api_uploads_volume require_release_image @@ -34,4 +35,4 @@ wait_for_healthy admin 180 echo "Pruning old images" docker image prune -f >/dev/null -echo "Production release ${APP_IMAGE}:${APP_VERSION} is up and healthy." +echo "Production release ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} is up and healthy."