diff --git a/.env.docker.production.example b/.env.docker.production.example index 42f3e7f..ffb13ea 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -4,23 +4,29 @@ API_DOMAIN=api.example.com PUBLIC_SITE_DOMAIN=example.com PGMANAGE_DOMAIN=pgmanage.example.com PORTAINER_DOMAIN=portainer.example.com +REGISTRY_DOMAIN=registry.rentaldrivego.ma # ── 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. # APP_IMAGE=registry.example.com/rentaldrivego/car_management_system # APP_VERSION=latest +REGISTRY_HOST=registry.rentaldrivego.ma +REGISTRY_USER=rentaldrivego_registry +REGISTRY_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK +REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system # ── Database ────────────────────────────────────────────────────────────────── # Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false) -DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego +DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego # Build the URL from individual vars (set to true to use POSTGRES_* vars below) DATABASE_URL_FROM_POSTGRES=true POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=rentaldrivego POSTGRES_USER=postgres -POSTGRES_PASSWORD=change-me +POSTGRES_PASSWORD=24DY&1u5FLCkNMeiO_p + # ── Cache ───────────────────────────────────────────────────────────────────── REDIS_URL=redis://redis:6379 @@ -30,46 +36,50 @@ API_PORT=4000 # SSR server-side calls go via the internal Docker network API_INTERNAL_URL=http://api:4000/api/v1 # Public-facing API URL visible to browsers — MUST be your real domain, not localhost -API_URL=https://api.example.com +API_URL=https://api.rentaldrivego.ma # Baked into Next.js bundles at build time — MUST be set before running `npm run build` -NEXT_PUBLIC_API_URL=https://api.example.com/api/v1 +NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1 + # ── Frontend public URLs ────────────────────────────────────────────────────── # Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps) -NEXT_PUBLIC_MARKETING_URL=https://example.com -NEXT_PUBLIC_MARKETPLACE_URL=https://example.com -NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard -NEXT_PUBLIC_ADMIN_URL=https://example.com/admin -NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com -DASHBOARD_URL=https://example.com/dashboard -ADMIN_URL=https://example.com/admin +NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma +NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma +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 ────────────────────────────────────────────────────────────────────── # Comma-separated list of allowed browser origins. REQUIRED in production. CORS_ORIGINS=https://example.com,https://www.example.com # ── Auth ────────────────────────────────────────────────────────────────────── -JWT_SECRET=replace-with-a-long-random-secret +JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d NODE_ENV=production + # ── Email ───────────────────────────────────────────────────────────────────── -EMAIL_FROM=noreply@example.com +EMAIL_FROM=rentaldrivego@gmail.com EMAIL_FROM_NAME=RentalDriveGo # Option A — Resend -#RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx +#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V # Option B — SMTP (Gmail) MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp -MAIL_USERNAME=your-smtp-user@example.com -MAIL_PASSWORD=replace-with-an-app-password -MAIL_FROM_ADDRESS=noreply@example.com +MAIL_USERNAME=rentaldrivego@gmail.com +MAIL_PASSWORD="xmhg ibqy muoc rntc" +MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo -MAIL_REPLY_TO_ADDRESS=support@example.com +MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_NAME=RentalDriveGo + # ── Firebase push notifications (optional) ──────────────────────────────────── # FIREBASE_PROJECT_ID=your-firebase-project-id # FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com diff --git a/.env.docker.production.generated b/.env.docker.production.generated index 2a229b6..42ad5b3 100644 --- a/.env.docker.production.generated +++ b/.env.docker.production.generated @@ -4,6 +4,7 @@ API_DOMAIN=api.example.com PUBLIC_SITE_DOMAIN=example.com PGMANAGE_DOMAIN=pgmanage.example.com PORTAINER_DOMAIN=portainer.example.com +REGISTRY_DOMAIN=registry.example.com # Database DATABASE_URL_FROM_POSTGRES=true diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index acdf840..cd7d4b5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -108,9 +108,9 @@ build_image: - docker push $DOCKER_IMAGE - docker push $DOCKER_IMAGE_LATEST rules: - # Skip the container publish step unless either the GitLab registry or an - # explicit fallback registry configuration is available. - - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && (($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE))' + # On the default branch, always attempt the image build so missing registry + # configuration fails loudly in before_script instead of skipping the job. + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - when: never # ==================================== @@ -147,10 +147,10 @@ deploy_to_vps: echo "-- Syncing deployment assets --" ssh $VPS_USER@$VPS_IP " set -e - mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' + mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' " - scp docker-compose.production.yml docker-compose.portainer.production.yml traefik.yaml $VPS_USER@$VPS_IP:$DEPLOY_ROOT/ - scp scripts/docker-prod-common.sh scripts/docker-prod-deploy.sh $VPS_USER@$VPS_IP:$DEPLOY_ROOT/scripts/ + scp docker-compose.production.yml docker-compose.portainer.production.yml docker-compose.registry.production.yml traefik.yaml $VPS_USER@$VPS_IP:$DEPLOY_ROOT/ + scp scripts/docker-prod-common.sh scripts/docker-prod-deploy.sh scripts/docker-prod-up-registry.sh $VPS_USER@$VPS_IP:$DEPLOY_ROOT/scripts/ scp docker/pgmanage/override.py $VPS_USER@$VPS_IP:$DEPLOY_ROOT/docker/pgmanage/ echo "-- Running deploy script on VPS --" diff --git a/docker-compose.registry.production.yml b/docker-compose.registry.production.yml new file mode 100644 index 0000000..cdae356 --- /dev/null +++ b/docker-compose.registry.production.yml @@ -0,0 +1,34 @@ +name: rentaldrivego-registry-prod + +services: + registry: + image: registry:2 + restart: unless-stopped + networks: + - traefik-proxy + environment: + REGISTRY_HTTP_ADDR: 0.0.0.0:5000 + REGISTRY_HTTP_HOST: https://${REGISTRY_DOMAIN} + REGISTRY_HTTP_RELATIVEURLS: "true" + REGISTRY_AUTH: htpasswd + REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm + REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd + REGISTRY_STORAGE_DELETE_ENABLED: "true" + volumes: + - registry_prod_data:/var/lib/registry + - ./docker/registry/auth:/auth:ro + labels: + - traefik.enable=true + - traefik.http.routers.registry.rule=Host(`${REGISTRY_DOMAIN}`) + - traefik.http.routers.registry.entrypoints=websecure + - traefik.http.routers.registry.tls=true + - traefik.http.routers.registry.tls.certresolver=letsencrypt + - traefik.http.routers.registry.service=registry-svc + - traefik.http.services.registry-svc.loadbalancer.server.port=5000 + +networks: + traefik-proxy: + external: true + +volumes: + registry_prod_data: diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 9cb4fd7..ae589dc 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -7,6 +7,7 @@ Three Docker environments are available: - `Dockerfile.production` with `docker-compose.production.yml` - `docker-compose.pgmanage.yml` for a standalone pgManage container - `docker-compose.portainer.production.yml` for a standalone production Portainer deployment behind Traefik +- `docker-compose.registry.production.yml` for a standalone production Docker registry behind Traefik ### Development @@ -123,6 +124,7 @@ Add an A record for every subdomain to your server's public IP before deploying | `api.rentaldrivego.ma` | API | | `pgmanage.rentaldrivego.ma` | pgManage (DB admin) | | `portainer.rentaldrivego.ma` | Portainer | +| `registry.rentaldrivego.ma` | Docker registry | #### 2. Install Docker and clone the repo @@ -163,6 +165,18 @@ Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HO The example file uses `rentaldrivego.ma` for the marketplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`. Set `PORTAINER_DOMAIN=portainer.rentaldrivego.ma` in `.env.docker.production` to expose Portainer through Traefik. Set `PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma` to expose pgManage through Traefik, and set `PGMANAGE_LICENSE_KEY` if you are running the enterprise image. +Set `REGISTRY_DOMAIN=registry.rentaldrivego.ma` to expose the local Docker registry through Traefik. + +If you want to use a self-hosted registry on the same VPS, also set these values in `.env.docker.production`: + +```text +REGISTRY_HOST=registry.rentaldrivego.ma +REGISTRY_USER= +REGISTRY_PASSWORD= +REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system +``` + +The registry bootstrap script uses `REGISTRY_USER` and `REGISTRY_PASSWORD` to generate the htpasswd file mounted into the registry container, so the same credentials can be used by GitLab CI for `docker login`. #### 4a. Configure the registry for CI deploys @@ -173,13 +187,13 @@ If you want GitLab CI to build once and deploy by pulling a prebuilt image on th - Explicit registry variables: Set `REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASSWORD`, and `REGISTRY_IMAGE` yourself. This works for GitLab Registry, Docker Hub, GHCR, or another OCI registry. -Example explicit values for GitLab's hosted registry: +Example explicit values for a local registry exposed through Traefik: ```text -REGISTRY_HOST=gitlab.rentaldrivego.ma:5050 -REGISTRY_IMAGE=gitlab.rentaldrivego.ma:5050/rental_car/car_management_system -REGISTRY_USER= -REGISTRY_PASSWORD= +REGISTRY_HOST=registry.rentaldrivego.ma +REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system +REGISTRY_USER= +REGISTRY_PASSWORD= ``` The production compose file reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The GitLab deploy job injects those values automatically and now syncs the deployment assets to the VPS before running the server-side deploy script. Production no longer depends on `git pull` during release. @@ -215,6 +229,7 @@ npm run docker:prod:start:admin npm run docker:prod:start:frontends npm run docker:prod:start:pgmanage npm run docker:prod:start:portainer +npm run docker:prod:start:registry npm run docker:prod:start:all npm run docker:prod:start:api && npm run docker:prod:start:frontends ``` @@ -250,6 +265,29 @@ Equivalent raw Docker Compose command: docker compose -p rentaldrivego-portainer-prod --env-file .env.docker.production -f docker-compose.portainer.production.yml up -d ``` +#### 6b. Deploy the local registry + +The registry is deployed separately from the main app stack and reuses the shared `traefik-proxy` network managed by Traefik. + +```bash +npm run docker:prod:start:registry +``` + +Equivalent raw Docker Compose command: + +```bash +docker compose -p rentaldrivego-registry-prod --env-file .env.docker.production -f docker-compose.registry.production.yml up -d +``` + +On first start, the script generates `docker/registry/auth/htpasswd` from `REGISTRY_USER` and `REGISTRY_PASSWORD` in `.env.docker.production`. After the registry is up, verify it with: + +```bash +docker login registry.rentaldrivego.ma +docker pull registry:2 +docker tag registry:2 registry.rentaldrivego.ma/smoke-test:latest +docker push registry.rentaldrivego.ma/smoke-test:latest +``` + Uploaded assets handled by the API, including vehicle photos and company branding images, are persisted in the named Docker volume `api_uploads`, mounted inside the API container at `/var/lib/rentaldrivego/storage`. Rebuilding or redeploying the API no longer clears those files as long as that volume is kept. The production helper scripts and the GitLab deploy job now explicitly create the Docker volume `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads` before starting or redeploying the API stack, so a fresh server bootstrap does not accidentally start the API without its upload storage volume. diff --git a/package.json b/package.json index f079d96..9cbc31f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "docker:prod:start:frontends": "bash scripts/docker-prod-up-frontends.sh", "docker:prod:start:pgmanage": "bash scripts/docker-prod-up-pgmanage.sh", "docker:prod:start:portainer": "bash scripts/docker-prod-up-portainer.sh", + "docker:prod:start:registry": "bash scripts/docker-prod-up-registry.sh", "docker:prod:deploy": "bash scripts/docker-prod-deploy.sh", "docker:prod:up": "bash scripts/docker-prod-up-all.sh", "docker:prod:down": "docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml down", diff --git a/scripts/docker-prod-common.sh b/scripts/docker-prod-common.sh index e36c512..76e200f 100755 --- a/scripts/docker-prod-common.sh +++ b/scripts/docker-prod-common.sh @@ -5,13 +5,17 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.production.yml" PORTAINER_COMPOSE_FILE="${ROOT_DIR}/docker-compose.portainer.production.yml" +REGISTRY_COMPOSE_FILE="${ROOT_DIR}/docker-compose.registry.production.yml" TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml" ENV_FILE="${ROOT_DIR}/.env.docker.production" PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}" PORTAINER_PROJECT_NAME="${DOCKER_PORTAINER_PROJECT_NAME:-rentaldrivego-portainer-prod}" +REGISTRY_PROJECT_NAME="${DOCKER_REGISTRY_PROJECT_NAME:-rentaldrivego-registry-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}" +REGISTRY_AUTH_DIR="${ROOT_DIR}/docker/registry/auth" +REGISTRY_AUTH_FILE="${REGISTRY_AUTH_DIR}/htpasswd" ensure_env_file() { if [[ ! -f "${ENV_FILE}" ]]; then @@ -43,6 +47,10 @@ portainer_compose() { docker compose -p "${PORTAINER_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PORTAINER_COMPOSE_FILE}" "$@" } +registry_compose() { + docker compose -p "${REGISTRY_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${REGISTRY_COMPOSE_FILE}" "$@" +} + traefik_compose() { docker compose --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@" } @@ -141,6 +149,30 @@ start_portainer() { portainer_compose up -d } +ensure_registry_auth_file() { + mkdir -p "${REGISTRY_AUTH_DIR}" + + if [[ -n "${REGISTRY_USER:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then + docker run --rm --entrypoint htpasswd httpd:2-alpine -Bbn "${REGISTRY_USER}" "${REGISTRY_PASSWORD}" > "${REGISTRY_AUTH_FILE}" + chmod 600 "${REGISTRY_AUTH_FILE}" + return 0 + fi + + if [[ -f "${REGISTRY_AUTH_FILE}" ]]; then + return 0 + fi + + echo "Set REGISTRY_USER and REGISTRY_PASSWORD in .env.docker.production or create ${REGISTRY_AUTH_FILE} manually." >&2 + exit 1 +} + +start_registry() { + ensure_env_file + ensure_traefik_network + ensure_registry_auth_file + registry_compose up -d +} + start_traefik() { ensure_env_file ensure_traefik_network diff --git a/scripts/docker-prod-up-registry.sh b/scripts/docker-prod-up-registry.sh new file mode 100644 index 0000000..fdb8582 --- /dev/null +++ b/scripts/docker-prod-up-registry.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_registry + +echo "Registry is starting."