fix prod deployment
Build & Deploy / Build & Push Docker Image (push) Failing after 34s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 57s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Has been cancelled

This commit is contained in:
root
2026-07-01 01:37:37 -04:00
parent 13d0512048
commit 5dfd5b1814
37 changed files with 2156 additions and 61 deletions
+84
View File
@@ -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
+131
View File
@@ -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`
+326
View File
@@ -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."
+347
View File
@@ -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}
+6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+69
View File
@@ -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=<container-id-or-name> ./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}"
+1
View File
@@ -0,0 +1 @@
+111
View File
@@ -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/<timestamp>/
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-<timestamp> --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-<timestamp>/
cat backups/rentaldrivego-prod-<timestamp>/manifest.txt
```
Test that the database dump is valid:
```bash
pg_restore --list backups/rentaldrivego-prod-<timestamp>/postgres.dump | head -20
```
+6
View File
@@ -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
+100
View File
@@ -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" <<EOF
project=${PROJECT_NAME}
created_at_utc=${TIMESTAMP}
database_backup=postgres.dump
uploads_backup=api-uploads.tar.gz
traefik_backup=traefik-letsencrypt.tar.gz
volume_backup_dir=volumes
EOF
echo "Backup complete:"
echo " - ${BACKUP_DIR}/postgres.dump"
echo " - ${BACKUP_DIR}/api-uploads.tar.gz"
if [[ -f "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" ]]; then
echo " - ${BACKUP_DIR}/traefik-letsencrypt.tar.gz"
fi
if compgen -G "${VOLUME_BACKUP_DIR}/*.tar.gz" > /dev/null; then
echo " - ${VOLUME_BACKUP_DIR}/*.tar.gz"
fi
echo " - ${BACKUP_DIR}/manifest.txt"
+340
View File
@@ -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:-<unset>}." >&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"
}
+38
View File
@@ -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:-<missing>}}"
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."
+114
View File
@@ -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 <backup-dir> --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}"
+105
View File
@@ -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}"
+11
View File
@@ -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."
+15
View File
@@ -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"
+11
View File
@@ -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."
+11
View File
@@ -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."
+14
View File
@@ -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."
+11
View File
@@ -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."
+21
View File
@@ -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."
+10
View File
@@ -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."
+10
View File
@@ -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."
+11
View File
@@ -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."
+10
View File
@@ -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."
+36
View File
@@ -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: