#!/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}" DEFAULT_ACME_EMAIL="rentaldrivego@gmail.com" DEFAULT_API_DOMAIN="api.rentaldrivego.ma" DEFAULT_PUBLIC_SITE_DOMAIN="rentaldrivego.ma" DEFAULT_CORS_ORIGINS="https://rentaldrivego.ma,https://www.rentaldrivego.ma" 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" ' /^[[:space:]]*#/ || index($0, "=") == 0 { next } { name = $1 sub(/^[[:space:]]*export[[:space:]]+/, "", name) gsub(/^[[:space:]]+|[[:space:]]+$/, "", name) if (name == key) { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) } } 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 \ ACME_EMAIL \ APP_IMAGE \ APP_VERSION \ IMAGE_TAG \ IMAGE_ARCHIVE_DIR \ IMAGE_ARCHIVE_REQUIRED \ REGISTRY_HOST \ REGISTRY_USER \ REGISTRY_PASSWORD \ VPN_CONTAINER do export_env_value_if_unset "${key}" done } validate_prod_env_file() { local acme_email api_domain public_site_domain cors_origins postgres_password redis_password jwt_secret acme_email="$(read_env_value ACME_EMAIL)" 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 "${acme_email}" || "${acme_email}" != *@* || "${acme_email}" == *example.com* ]]; then echo "Invalid ACME_EMAIL in ${ENV_FILE}. Set ACME_EMAIL=${DEFAULT_ACME_EMAIL} for Let's Encrypt." >&2 exit 1 fi if [[ "${api_domain}" != "${DEFAULT_API_DOMAIN}" ]]; then echo "Invalid API_DOMAIN in ${ENV_FILE}. Set API_DOMAIN=${DEFAULT_API_DOMAIN}." >&2 exit 1 fi if [[ "${public_site_domain}" != "${DEFAULT_PUBLIC_SITE_DOMAIN}" ]]; then echo "Invalid PUBLIC_SITE_DOMAIN in ${ENV_FILE}. Set PUBLIC_SITE_DOMAIN=${DEFAULT_PUBLIC_SITE_DOMAIN}." >&2 exit 1 fi if [[ "${cors_origins}" != "${DEFAULT_CORS_ORIGINS}" ]]; then echo "Invalid CORS_ORIGINS in ${ENV_FILE}. Set CORS_ORIGINS=${DEFAULT_CORS_ORIGINS}." >&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() { local archive_disabled="${IMAGE_ARCHIVE_DISABLED:-false}" if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then if [[ "${archive_disabled}" != "true" && "${archive_disabled}" != "1" ]]; then echo "IMAGE_ARCHIVE_REQUIRED is enabled; skipping docker login." return 0 fi 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 verify_prod_release_image 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 "Direct docker pull is disabled in this mode." >&2 echo "The VPS is configured for VPN/shared-folder image handoff. Create the archive first, for example:" >&2 echo " VPN_CONTAINER= ./fetch-image-through-vpn.sh ${IMAGE_TAG}" >&2 echo "Or let deploy fetch it before starting services:" >&2 echo " VPN_CONTAINER= ./deploy-production.sh --fetch-through-vpn" >&2 echo "You can also set VPN_CONTAINER in .env.docker.production, then run:" >&2 echo " ./deploy-production.sh" >&2 exit 1 fi prod_compose pull migrate api homepage storefront dashboard admin verify_prod_release_image } verify_prod_release_image() { require_release_image local image="${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}" echo "Verifying production image contents: ${image}" if ! docker image inspect "${image}" >/dev/null 2>&1; then echo "Production image is not available locally after load/pull: ${image}" >&2 exit 1 fi if ! docker run --rm --entrypoint sh "${image}" -c 'test -f /app/config/nextSecurityHeaders.js' >/dev/null 2>&1; then echo "Production image is missing /app/config/nextSecurityHeaders.js: ${image}" >&2 echo "This image cannot start the Next.js apps because their next.config.js files require ../../config/nextSecurityHeaders." >&2 echo "Build and transfer a fresh image from the full repository, then rerun deployment." >&2 exit 1 fi } load_release_image_archives() { local archive_dir="${IMAGE_ARCHIVE_DIR:-}" local archive local loaded=0 if [[ "${IMAGE_ARCHIVE_DISABLED:-false}" == "true" || "${IMAGE_ARCHIVE_DISABLED:-0}" == "1" ]]; then echo "Image archive loading is disabled; pulling from registry." return 1 fi 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" }