fix prod ddeployment

This commit is contained in:
root
2026-06-02 12:53:45 -04:00
parent b5a974ee14
commit e9dfbeb591
9 changed files with 224 additions and 104 deletions
+25 -25
View File
@@ -1,26 +1,26 @@
# ── Traefik domains — used in docker-compose labels ──────────────────────────
ACME_EMAIL=rentaldrivego@gmail.com
API_DOMAIN=api.rentaldrivego.ma
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
ACME_EMAIL=ops@example.com
API_DOMAIN=api.example.com
PUBLIC_SITE_DOMAIN=example.com
PGMANAGE_DOMAIN=pgmanage.example.com
PORTAINER_DOMAIN=portainer.example.com
# ── 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=gitlab.rentaldrivego.ma:5050/rental_car/car_management_system
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
# APP_VERSION=latest
# ── Database ──────────────────────────────────────────────────────────────────
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego
DATABASE_URL=postgresql://postgres:change-me@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=24DY&1u5FLCkNMeiO_p
POSTGRES_PASSWORD=change-me
# ── Cache ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://redis:6379
@@ -30,44 +30,44 @@ 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.rentaldrivego.ma
API_URL=https://api.example.com
# Baked into Next.js bundles at build time — MUST be set before running `npm run build`
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# ── Frontend public URLs ──────────────────────────────────────────────────────
# 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_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
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
# ── CORS ──────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed browser origins. REQUIRED in production.
CORS_ORIGINS=https://rentaldrivego.ma
CORS_ORIGINS=https://example.com,https://www.example.com
# ── Auth ──────────────────────────────────────────────────────────────────────
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
JWT_SECRET=replace-with-a-long-random-secret
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
NODE_ENV=production
# ── Email ─────────────────────────────────────────────────────────────────────
EMAIL_FROM=rentaldrivego@gmail.com
EMAIL_FROM=noreply@example.com
EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
#RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
# Option B — SMTP (Gmail)
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD="xmhg ibqy muoc rntc"
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_USERNAME=your-smtp-user@example.com
MAIL_PASSWORD=replace-with-an-app-password
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_ADDRESS=support@example.com
MAIL_REPLY_TO_NAME=RentalDriveGo
# ── Firebase push notifications (optional) ────────────────────────────────────
+24 -24
View File
@@ -1,9 +1,9 @@
# Traefik domains
ACME_EMAIL=rentaldrivego@gmail.com
API_DOMAIN=api.rentaldrivego.ma
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
ACME_EMAIL=ops@example.com
API_DOMAIN=api.example.com
PUBLIC_SITE_DOMAIN=example.com
PGMANAGE_DOMAIN=pgmanage.example.com
PORTAINER_DOMAIN=portainer.example.com
# Database
DATABASE_URL_FROM_POSTGRES=true
@@ -11,8 +11,8 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=rentaldrivego
POSTGRES_USER=postgres
POSTGRES_PASSWORD=24DY&1u5FLCkNMeiO_p
DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
# Cache
REDIS_URL=redis://redis:6379
@@ -20,23 +20,23 @@ REDIS_URL=redis://redis:6379
# API
API_PORT=4000
API_INTERNAL_URL=http://api:4000/api/v1
API_URL=https://api.rentaldrivego.ma
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs
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
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
# CORS
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
CORS_ORIGINS=https://example.com,https://www.example.com
# Auth
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
JWT_SECRET=replace-with-a-long-random-secret
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
NODE_ENV=production
@@ -46,17 +46,17 @@ FILE_STORAGE_ROOT=/var/lib/rentaldrivego/storage
# PgManage
PGMANAGE_DEFAULT_USERNAME=admin
PGMANAGE_DEFAULT_PASSWORD=24DY&1u5FLCkNMeiO_p
PGMANAGE_DEFAULT_PASSWORD=change-me
# Email
EMAIL_FROM=rentaldrivego@gmail.com
EMAIL_FROM=noreply@example.com
EMAIL_FROM_NAME=RentalDriveGo
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=xmhg ibqy muoc rntc
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_USERNAME=your-smtp-user@example.com
MAIL_PASSWORD=replace-with-an-app-password
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_ADDRESS=support@example.com
MAIL_REPLY_TO_NAME=RentalDriveGo
+19 -26
View File
@@ -5,6 +5,7 @@ stages:
variables:
DOCKERFILE_PATH: Dockerfile.production
DEPLOY_ROOT: /opt/rentaldrivego
# Required: disable TLS so the docker client can reach the dind daemon
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
@@ -141,35 +142,27 @@ deploy_to_vps:
script:
- echo "--- Deploying $DOCKER_IMAGE to VPS ---"
- |
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
echo "-- Syncing deployment assets --"
ssh $VPS_USER@$VPS_IP "
set -e
cd /opt/rentaldrivego
mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage'
"
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/pgmanage/override.py $VPS_USER@$VPS_IP:$DEPLOY_ROOT/docker/pgmanage/
echo '-- Pulling latest code --'
git pull
echo '-- Logging into registry --'
test -n '$REGISTRY_HOST' || { echo 'Registry host is not set.'; exit 1; }
test -n '$REGISTRY_USER' || { echo 'Registry user is not set.'; exit 1; }
test -n '$REGISTRY_PASSWORD' || { echo 'Registry password is not set.'; exit 1; }
echo '$REGISTRY_PASSWORD' | docker login '$REGISTRY_HOST' -u '$REGISTRY_USER' --password-stdin
echo '-- Pulling pre-built image --'
docker pull $DOCKER_IMAGE
echo '-- Ensuring uploads volume exists --'
docker volume create rentaldrivego-prod_api_uploads >/dev/null
echo '-- Restarting services --'
APP_IMAGE=$IMAGE_REPOSITORY \
APP_VERSION=$CI_COMMIT_SHORT_SHA \
docker compose -p rentaldrivego-prod \
--env-file .env.docker.production \
-f docker-compose.production.yml \
up -d --no-build
echo '-- Pruning old images --'
docker image prune -f
echo "-- Running deploy script on VPS --"
ssh $VPS_USER@$VPS_IP "
set -e
cd '$DEPLOY_ROOT'
APP_IMAGE='$IMAGE_REPOSITORY' \
APP_VERSION='$CI_COMMIT_SHORT_SHA' \
REGISTRY_HOST='$REGISTRY_HOST' \
REGISTRY_USER='$REGISTRY_USER' \
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
bash scripts/docker-prod-deploy.sh
"
rules:
# Deploy only when both registry access and VPS credentials are available.
+33 -9
View File
@@ -26,6 +26,32 @@ services:
volumes:
- redis_prod_data:/data
migrate:
image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile.production
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL}
NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL}
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL}
command: ["npm", "run", "db:deploy"]
networks:
- internal
depends_on:
postgres:
condition: service_healthy
env_file:
- .env.docker.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:
image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
build:
@@ -37,12 +63,7 @@ services:
timeout: 10s
retries: 5
start_period: 60s
command:
- bash
- -c
- |
npm run db:deploy &&
npm run start --workspace @rentaldrivego/api
command: ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
networks:
- internal
- traefik-proxy
@@ -92,7 +113,8 @@ services:
- internal
- traefik-proxy
depends_on:
- api
api:
condition: service_healthy
env_file:
- .env.docker.production
restart: unless-stopped
@@ -127,7 +149,8 @@ services:
- internal
- traefik-proxy
depends_on:
- api
api:
condition: service_healthy
env_file:
- .env.docker.production
restart: unless-stopped
@@ -162,7 +185,8 @@ services:
- internal
- traefik-proxy
depends_on:
- api
api:
condition: service_healthy
env_file:
- .env.docker.production
restart: unless-stopped
+29 -18
View File
@@ -182,27 +182,27 @@ REGISTRY_USER=<deploy-user-or-token-name>
REGISTRY_PASSWORD=<personal-access-token-or-deploy-token>
```
The production compose file now reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The GitLab deploy job injects those values automatically. If you want to pull manually on the server, you can also set `APP_IMAGE` in `.env.docker.production`.
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.
The CI pipeline publishes and deploys from the GitLab default branch (`$CI_DEFAULT_BRANCH`). If you change your release branch, update the repository default branch in GitLab instead of hard-coding branch names in `.gitlab-ci.yml`.
If you use `docker:dind` on a self-hosted GitLab runner, the runner's Docker executor must be started with `privileged = true`. Without that, `docker:dind` often logs AppArmor or `/sys/kernel/security` mount errors and can become unreliable even when the job container still starts.
#### 5. Start Traefik
#### 5. Bootstrap the server once
Traefik must be running before the app stack so it can wire up routes at startup.
Traefik must be running before the app stack so it can wire up routes at startup. This bootstrap path is for the first server setup or for deliberate source-based rebuilds. Normal production releases should go through GitLab CI.
```bash
docker compose -f traefik.yaml up -d
bash scripts/docker-prod-up-traefik.sh
```
#### 6. Build and start the app stack
If you intentionally want to build from source on the server:
```bash
npm run docker:prod:up
```
Or use the new helper scripts if you want to start one production container at a time:
Or use the helper scripts if you want to start one production container at a time:
```bash
npm run docker:prod:start:traefik
@@ -220,11 +220,22 @@ npm run docker:prod:start:api && npm run docker:prod:start:frontends
```
Docker will:
1. Build the monorepo image
1. Build the monorepo image on the server
2. Start all app services (`api`, `marketplace`, `dashboard`, `admin`, `pgmanage`)
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
#### 6. Standard release flow
Normal production releases should use GitLab CI:
1. CI builds and pushes a single versioned image.
2. CI copies the current deployment assets (`docker-compose.production.yml`, Traefik config, deploy scripts, pgManage override) to the VPS.
3. CI runs `bash scripts/docker-prod-deploy.sh` on the VPS with `APP_IMAGE` and `APP_VERSION` pinned to the commit being released.
4. The deploy script pulls the release image, starts Postgres and Redis, runs `npm run db:deploy` as a one-shot migration job, then starts the API and frontends and waits for their health checks.
This avoids the old drift where the VPS checkout changed independently through `git pull`, or where API startup silently mixed migrations with application boot.
#### 6a. Deploy Portainer
Portainer is deployed separately from the main app stack and reuses the shared `traefik-proxy` network managed by Traefik.
@@ -245,25 +256,25 @@ The production helper scripts and the GitLab deploy job now explicitly create th
#### Updating after a code change
Pull the latest code and rebuild only the changed service:
Push to the GitLab default branch and let the pipeline deploy the pinned image. That is the supported production release path.
If CI is unavailable and you need to redeploy the image already published to the registry from the server:
```bash
git pull
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml up --build -d --no-deps <service>
# e.g. to redeploy only the API:
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml up --build -d --no-deps api
APP_IMAGE=registry.example.com/rentaldrivego/car_management_system \
APP_VERSION=<gitlab-commit-sha> \
REGISTRY_HOST=registry.example.com \
REGISTRY_USER=<registry-user> \
REGISTRY_PASSWORD=<registry-password> \
bash scripts/docker-prod-deploy.sh
```
To rebuild everything:
```bash
npm run docker:prod:up
```
Only use `npm run docker:prod:up` when you intentionally want a source build on the server.
#### Apply database migrations without downtime
```bash
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml run --rm api npm run db:deploy
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml run --rm migrate
```
#### Create the first production admin
+2 -1
View File
@@ -34,7 +34,8 @@
"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:up": "docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml up --build -d",
"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",
"docker:prod:backup": "bash scripts/docker-prod-backup.sh",
"docker:prod:restore": "bash scripts/docker-prod-restore.sh",
+54
View File
@@ -50,9 +50,63 @@ traefik_compose() {
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|marketplace|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() {
if [[ -z "${APP_IMAGE:-}" || -z "${APP_VERSION:-}" ]]; then
echo "APP_IMAGE and APP_VERSION must be set for pull-based 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
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
exit 1
fi
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin
}
pull_prod_release_images() {
require_release_image
prod_compose pull migrate api marketplace dashboard admin
}
run_prod_migrations() {
prod_compose run --rm migrate
}
wait_for_healthy() {
local service="$1"
local timeout="${2:-120}"
+37
View File
@@ -0,0 +1,37 @@
#!/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:-<missing>}:${APP_VERSION:-<missing>}"
ensure_env_file
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 marketplace dashboard admin pgmanage
wait_for_healthy api 180
wait_for_healthy marketplace 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}:${APP_VERSION} is up and healthy."
+1 -1
View File
@@ -6,7 +6,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
start_traefik
start_prod_services
start_prod_services postgres redis api marketplace dashboard admin pgmanage
wait_for_healthy api
wait_for_healthy marketplace
wait_for_healthy dashboard