#!/usr/bin/env bash 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" DYNAMIC_DIR="${ROOT_DIR}/dynamic" REGISTRY_DYNAMIC_FILE="${DYNAMIC_DIR}/registry-upstream.yml" 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 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 } export_compose_env_from_file() { local line key value while IFS= read -r line || [[ -n "${line}" ]]; do line="${line%$'\r'}" [[ -z "${line//[[:space:]]/}" || "${line}" =~ ^[[:space:]]*# ]] && continue line="${line#"${line%%[![:space:]]*}"}" line="${line#export }" [[ "${line}" == *"="* ]] || continue key="${line%%=*}" value="${line#*=}" key="${key//[[:space:]]/}" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then value="${value#\"}" value="${value%\"}" elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then value="${value#\'}" value="${value%\'}" fi if [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then export "${key}=${value}" fi done < "${ENV_FILE}" } 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 do export_env_value_if_unset "${key}" done } validate_prod_env_file() { local acme_email api_domain public_site_domain cors_origins portainer_domain 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)" portainer_domain="$(read_env_value PORTAINER_DOMAIN)" 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 it to a real contact email for Let's Encrypt, not example.com." >&2 exit 1 fi 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 if [[ -n "${portainer_domain}" && "${portainer_domain}" == *example.com* ]]; then echo "Invalid PORTAINER_DOMAIN in ${ENV_FILE}. Set a real hostname or disable the portainer router." >&2 exit 1 fi } 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 } ensure_dynamic_dir() { mkdir -p "${DYNAMIC_DIR}" } prod_compose() { export_compose_env_from_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() { export_compose_env_from_file docker compose -p "${PORTAINER_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PORTAINER_COMPOSE_FILE}" "$@" } registry_compose() { export_compose_env_from_file docker compose -p "${REGISTRY_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${REGISTRY_COMPOSE_FILE}" "$@" } traefik_compose() { export_compose_env_from_file 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_portainer() { ensure_env_file ensure_traefik_network portainer_compose up -d } render_registry_proxy_config() { ensure_dynamic_dir if [[ -z "${REGISTRY_DOMAIN:-}" && -z "${REGISTRY_UPSTREAM_URL:-}" ]]; then rm -f "${REGISTRY_DYNAMIC_FILE}" return 0 fi if [[ -z "${REGISTRY_DOMAIN:-}" || -z "${REGISTRY_UPSTREAM_URL:-}" ]]; then echo "REGISTRY_DOMAIN and REGISTRY_UPSTREAM_URL must either both be set or both be unset." >&2 exit 1 fi cat > "${REGISTRY_DYNAMIC_FILE}" < "${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 render_registry_proxy_config 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" }