Files
carmanagement/docs/DOCKER.md
T
root be30d19cdb
Build & Deploy / Build & Push Docker Image (push) Failing after 18s
Build & Deploy / Deploy to VPS (push) Has been skipped
build fix 11
2026-07-04 15:45:38 -04:00

24 KiB

Docker Environments

Three Docker environments are available:

  • Dockerfile.dev with docker-compose.dev.yml
  • Dockerfile.test with docker-compose.test.yml
  • Dockerfile.production with docker-compose.production.yml
  • docker-compose.pgmanage.yml for a standalone pgManage container
  • docker-compose.portainer.production.yml for a standalone production Portainer deployment behind Traefik
  • docker-compose.registry.production.yml for a standalone production Docker registry behind Traefik
  • docker-compose.registry.local.yml for a Docker registry running on a separate local server with port 5000 exposed directly

Development

Use the full dev stack for local work with hot reload and bundled Postgres and Redis:

docker compose -f docker-compose.dev.yml --profile full up --build

Services:

  • carplace: http://localhost:3000
  • dashboard: http://localhost:3000/dashboard
  • admin: http://localhost:3000/admin
  • public-site: http://localhost:3003
  • api: http://localhost:4000
  • pgAdmin: http://localhost:5050

Each dev app now runs in its own container and can be started independently with a profile tag:

docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile carplace up --build
docker compose -f docker-compose.dev.yml --profile dashboard up --build
docker compose -f docker-compose.dev.yml --profile admin up --build
docker compose -f docker-compose.dev.yml --profile public-site up --build
docker compose -f docker-compose.dev.yml --profile tools up --build
docker compose -f docker-compose.dev.yml --profile api --profile carplace --profile dashboard --profile admin up --build

Notes:

  • api starts postgres, redis, and migrate automatically through dependencies.
  • frontend profiles also start api and its dependencies automatically.
  • tools starts only pgadmin plus its required postgres dependency.

On startup, Docker now waits for Postgres to become healthy, runs a one-shot migrate service, and only then starts the selected app container. For development, that bootstrap runs db:generate every time, but db:deploy and db:seed only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.

Uploaded assets handled by the API, including vehicle photos and company branding images, are stored in the named Docker volume api_uploads_dev at /var/lib/rentaldrivego/storage inside the container. They stay available across normal container rebuilds and restarts.

Default dev platform administrator:

  • email: admin@rentaldrivego.com
  • password: changeme123

If you intentionally want a fresh dev bootstrap:

docker compose -f docker-compose.dev.yml down -v

If you want to keep the database and only apply new schema changes manually:

docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"

pgAdmin dev login:

  • email: admin@rentaldrivego.local
  • email: admin@rentaldrivego.dev
  • password: admin

pgAdmin opens with the dev Postgres server pre-registered as RentalDriveGo Dev DB.

pgAdmin Postgres connection:

  • host: postgres
  • port: 5432
  • database: rentaldrivego
  • username: postgres
  • password: password

Standalone pgManage

If you want a standalone Postgres management UI without starting the full development stack:

docker compose -f docker-compose.pgmanage.yml up -d

It publishes http://localhost:8000 with a standard Docker port mapping and persists its data in the named Docker volume pgmanage_data. From inside the container, connect to the local Postgres service through host.docker.internal:5432. For local HTTP access, pgManage defaults PGMANAGE_SECURE_COOKIES=False. If you expose it behind HTTPS instead, set PGMANAGE_SECURE_COOKIES=True. If you are using the enterprise image, set PGMANAGE_LICENSE_KEY to remove the startup license error.

Test

Use the test stack to run repeatable containerized verification:

docker compose -f docker-compose.test.yml up --build --abort-on-container-exit

The test container runs:

  • npm run db:deploy
  • npm run db:generate
  • npm run type-check
  • npm run build
  • npm run test:api:integration

Production

The production stack runs behind Traefik (reverse proxy + automatic HTTPS via Let's Encrypt). All services communicate over a private Docker network (internal). Traefik reaches public-facing services via a separate traefik-proxy network.

1. Point DNS to your server

Add an A record for every subdomain to your server's public IP before deploying so Let's Encrypt can issue certificates:

Subdomain Service
rentaldrivego.ma carplace and public site
api.rentaldrivego.ma API
pgmanage.rentaldrivego.ma pgManage (DB admin)
portainer.rentaldrivego.ma Portainer
registry.rentaldrivego.ma Docker registry

2. Install Docker and clone the repo

# Install Docker (if not already installed)
curl -fsSL https://get.docker.com | sh

git clone <repo-url> rentaldrivego
cd rentaldrivego

3. Create the shared Traefik network

Only needs to be done once per server. If it already exists this is a no-op.

docker network create traefik-proxy

4. Configure environment variables

cp .env.docker.production.example .env.docker.production

Open .env.docker.production and fill in every value. The minimum required secrets are:

Variable What to set
POSTGRES_PASSWORD Strong random password
JWT_SECRET Long random string (e.g. openssl rand -hex 64)
ACME_EMAIL Your email for Let's Encrypt notifications
RESEND_API_KEY Resend API key (or configure SMTP vars instead)
PGMANAGE_DOMAIN Hostname for pgManage, e.g. pgmanage.rentaldrivego.ma

Production now derives DATABASE_URL inside the app container from POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD when DATABASE_URL_FROM_POSTGRES=true. That avoids Prisma auth failures when the database password contains reserved URL characters such as @, :, or /.

The example file uses rentaldrivego.ma for the carplace and public site. The dashboard and admin panel are routed under that same host at /dashboard and /admin. Set PORTAINER_DOMAIN=portainer.rentaldrivego.ma in .env.docker.production to expose Portainer through Traefik. Set PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma to expose pgManage through Traefik, and set PGMANAGE_LICENSE_KEY if you are running the enterprise image. Set REGISTRY_DOMAIN=registry.rentaldrivego.ma to expose the Docker registry through Traefik. Set REGISTRY_UPSTREAM_URL=http://<local-registry-server-ip>:5000 on the VPS when the registry itself runs on a different server and Traefik should reverse-proxy to it.

If you want to use a self-hosted registry on the same VPS, also set these values in .env.docker.production:

REGISTRY_HOST=registry.rentaldrivego.ma
REGISTRY_USER=<registry-user>
REGISTRY_PASSWORD=<strong-registry-password>
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system

The registry bootstrap script uses REGISTRY_USER and REGISTRY_PASSWORD to generate the htpasswd file mounted into the registry container, so the same credentials can be used by GitLab CI for docker login.

4a. Configure the registry for CI deploys

If you want GitLab CI to build once and deploy by pulling a prebuilt image on the VPS, configure one of these two modes in GitLab Settings > CI/CD > Variables:

  • Built-in GitLab Container Registry: Enable the project registry and use GitLab's default CI_REGISTRY, CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, and CI_REGISTRY_IMAGE variables.
  • Explicit registry variables: Set REGISTRY_HOST, REGISTRY_USER, REGISTRY_PASSWORD, and REGISTRY_IMAGE yourself. This works for GitLab Registry, Docker Hub, GHCR, or another OCI registry.

If REGISTRY_* variables are set, the CI pipeline now uses them as a complete override and requires all four values. If they are unset, the pipeline falls back to GitLab's CI_REGISTRY_* variables.

For production image builds, also define these GitLab CI/CD variables with their real public URLs:

NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin

The Docker build now fails if any of those values are missing or still point at example.com, because Next.js inlines them into the production frontend bundles at build time.

Example explicit values for a local registry exposed through Traefik:

REGISTRY_HOST=registry.rentaldrivego.ma
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
REGISTRY_USER=<registry-user>
REGISTRY_PASSWORD=<registry-password>

For the deploy job, add these Gitea Actions secrets as well:

VPS_IP=<server-ip-or-hostname>
VPS_USER=<ssh-user>
VPS_SSH_KEY=<deployment-private-key>

VPS_IP overrides the workflow's default DEPLOY_SSH_HOST. If VPS_IP is not set, the workflow uses DEPLOY_SSH_HOST from .gitea/workflows/build-and-deploy.yml.

VPS_SSH_KEY must be an unencrypted private key that OpenSSH can read in a non-interactive job. The workflow also accepts VPS_SSH_KEY_B64 when you prefer storing a base64-encoded private key as a single line. If both are set, VPS_SSH_KEY_B64 is used first; when it is not valid base64, the workflow falls back to VPS_SSH_KEY.

Supported private key formats:

  • Standard multiline key pasted directly into VPS_SSH_KEY
  • Single-line key with literal \n newline escapes in VPS_SSH_KEY
  • Base64-encoded private key stored as a single line in VPS_SSH_KEY_B64

If your key is passphrase-protected, generate a dedicated deploy key without a passphrase for CI instead of reusing an interactive workstation key.

To create a new dedicated deploy key, run this on your workstation or another trusted admin machine, not inside Gitea:

ssh-keygen -t ed25519 -f ./rentaldrivego_gitea_deploy -C "gitea-actions-rentaldrivego" -N ""
ssh-keygen -lf ./rentaldrivego_gitea_deploy.pub
base64 < ./rentaldrivego_gitea_deploy | tr -d '\n'

Use the generated files like this:

  • rentaldrivego_gitea_deploy: private key. Store this in Gitea as either raw VPS_SSH_KEY or base64-encoded VPS_SSH_KEY_B64.
  • rentaldrivego_gitea_deploy.pub: public key. Install this on the VPS in ~/.ssh/authorized_keys for the account configured as VPS_USER.

Do not put the .pub file or an authorized_keys line into VPS_SSH_KEY or VPS_SSH_KEY_B64. Gitea needs the private key; the VPS needs the public key.

Before saving the Gitea secret, verify the private key locally:

head -n 1 ./rentaldrivego_gitea_deploy
wc -c ./rentaldrivego_gitea_deploy
ssh-keygen -y -f ./rentaldrivego_gitea_deploy > ./rentaldrivego_gitea_deploy.pub

The first line must look like this:

-----BEGIN OPENSSH PRIVATE KEY-----

The private key is usually hundreds of bytes. If the workflow reports a decoded byte count such as 79, the Gitea secret is not the full private key. It is usually a public key, a fingerprint, or a truncated value.

To install the public key on the VPS, SSH into the server as the same account configured in VPS_USER, then run:

mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' '<contents-of-rentaldrivego_gitea_deploy.pub>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
ssh-keygen -lf ~/.ssh/authorized_keys

If you are logged in as root but VPS_USER is a different account such as deploy, install the public key under that user's home directory instead:

install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
printf '%s\n' '<contents-of-rentaldrivego_gitea_deploy.pub>' >> /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
ssh-keygen -lf /home/deploy/.ssh/authorized_keys

If SSH authentication fails after the workflow loads the key, compare the logged deploy key fingerprint with the key installed on the server:

ssh-keygen -lf ~/.ssh/authorized_keys

The matching public key must be present in ~/.ssh/authorized_keys for the account named by VPS_USER. For example, a failed workflow that logs fingerprint SHA256:mX9s/EluBlPKuKA+Vmc6HrbO6hpDLjBHtRi33mEOyhY requires the matching public key line on the VPS.

To install the workflow's logged Deploy public key value for the selected SSH user:

mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' '<deploy-public-key-from-workflow-log>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Run those commands while logged in as the same account configured in VPS_USER. If VPS_USER is deploy, install the key under /home/deploy/.ssh/authorized_keys; if it is root, install it under /root/.ssh/authorized_keys.

The production compose file reads APP_IMAGE and APP_VERSION for pull-based deploys. The Gitea deploy job injects those values automatically and 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. Bootstrap the server once

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 scripts/docker-prod-up-traefik.sh

If you intentionally want to build from source on the server:

npm run docker:prod:up

Or use the helper scripts if you want to start one production container at a time:

npm run docker:prod:start:traefik
npm run docker:prod:start:postgres
npm run docker:prod:start:redis
npm run docker:prod:start:api
npm run docker:prod:start:carplace
npm run docker:prod:start:dashboard
npm run docker:prod:start:admin
npm run docker:prod:start:frontends
npm run docker:prod:start:pgmanage
npm run docker:prod:start:portainer
npm run docker:prod:start:registry
npm run docker:prod:start:all
npm run docker:prod:start:api && npm run docker:prod:start:frontends

Docker will:

  1. Build the monorepo image on the server
  2. Start all app services (api, carplace, 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.

npm run docker:prod:start:portainer

Equivalent raw Docker Compose command:

docker compose -p rentaldrivego-portainer-prod --env-file .env.docker.production -f docker-compose.portainer.production.yml up -d

6b. Deploy the registry on a separate local server

For the split setup:

  1. Run the registry on your local server with port 5000 exposed directly.
  2. Set REGISTRY_UPSTREAM_URL on the VPS to http://<local-registry-server-ip>:5000.
  3. Restart Traefik on the VPS so it renders the dynamic registry upstream file and serves TLS for REGISTRY_DOMAIN.

On the local registry server:

npm run docker:registry:local:start

Equivalent raw Docker Compose command:

docker compose -p rentaldrivego-registry-local --env-file .env.docker.production -f docker-compose.registry.local.yml up -d

On first start, the script generates docker/registry/auth/htpasswd from REGISTRY_USER and REGISTRY_PASSWORD in .env.docker.production.

From the VPS, verify the upstream directly before involving Traefik:

curl -I http://<local-registry-server-ip>:5000/v2/

Expected response:

HTTP/1.1 401 Unauthorized

When CI builds on the VPS and pushes directly to the local registry, DEPLOY_REGISTRY_HOST in .gitea/workflows/build-and-deploy.yml must include the registry port, for example 10.0.0.4:5000. Because the local registry is plain HTTP, configure the VPS Docker daemon once:

{
  "insecure-registries": ["10.0.0.4:5000"]
}

If /etc/docker/daemon.json already exists, merge the insecure-registries value with the existing JSON instead of replacing the file. Then restart Docker:

sudo systemctl restart docker
docker login 10.0.0.4:5000

Then on the VPS restart Traefik:

bash scripts/docker-prod-up-traefik.sh

After that, verify the public route and login flow:

docker login registry.rentaldrivego.ma
docker pull registry:2
docker tag registry:2 registry.rentaldrivego.ma/smoke-test:latest
docker push registry.rentaldrivego.ma/smoke-test:latest

Uploaded assets handled by the API, including vehicle photos and company branding images, are persisted in the named Docker volume api_uploads, mounted inside the API container at /var/lib/rentaldrivego/storage. Rebuilding or redeploying the API no longer clears those files as long as that volume is kept.

The production helper scripts and the GitLab deploy job now explicitly create the Docker volume ${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads before starting or redeploying the API stack, so a fresh server bootstrap does not accidentally start the API without its upload storage volume.

Updating after a code change

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:

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

Only use npm run docker:prod:up when you intentionally want a source build on the server.

Apply database migrations without downtime

docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml run --rm migrate

Create the first production admin

The repo includes a seed that creates the first SUPER_ADMIN if that email does not already exist.

docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml run --rm \
  -e ADMIN_SEED_EMAIL=rentaldrivego@gmail.com \
  -e ADMIN_SEED_PASSWORD='Qwerty00!@#$%' \
  -e ADMIN_SEED_FIRST_NAME=Super \
  -e ADMIN_SEED_LAST_NAME=Admin \
  api npm run db:seed:admin

Then sign in at https://rentaldrivego.ma/admin/login and create any additional admin users from the admin panel.

View logs

# All services
npm run docker:prod:logs

# Single service
npm run docker:prod:logs:api

Backup production data

Create a timestamped backup directory under ./backups:

npm run docker:prod:backup

Or choose a different parent directory:

bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups

Each backup contains:

  • postgres.dump — logical PostgreSQL backup in custom format
  • api-uploads.tar.gz — uploaded files from /var/lib/rentaldrivego/storage
  • traefik-letsencrypt.tar.gz — Traefik ACME certificate state, when available
  • volumes/*.tar.gz — raw Docker volume archives for the default production volumes
  • manifest.txt — basic metadata

By default, the backup also archives these named production volumes when they exist:

  • ${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads
  • ${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_pgmanage_prod_data
  • ${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_postgres_prod_data
  • ${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_redis_prod_data

To include extra named volumes from the same server, pass them as additional arguments:

bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups \
  gitlab-l3gq_gitlab-config \
  gitlab-l3gq_gitlab-data \
  gitlab-l3gq_gitlab-logs \
  pgmanage_data \
  traefik-gcjk_portainer_data \
  traefik-gcjk_traefik-letsencrypt

You can also provide extra volume names through DOCKER_EXTRA_BACKUP_VOLUMES.

Restore production data

Restore is destructive: it overwrites the production database, uploaded files, and, when present in the backup, Traefik ACME state.

bash scripts/docker-prod-restore.sh ./backups/rentaldrivego-prod-YYYYMMDDTHHMMSSZ --yes

The restore script:

  1. Stops public app services
  2. Restores the PostgreSQL dump
  3. Replaces uploaded files
  4. Restores Traefik ACME state if the archive exists
  5. Restores raw volume archives from volumes/ except the ones already covered by the database and upload restore steps
  6. Starts Traefik and the production stack again

If the backup contains raw archives for volumes used by other stacks such as GitLab or Portainer, stop those containers before running restore. The script will refuse to overwrite a volume that is currently attached to a running container.

Before running restore on a live server, take a fresh backup first.

Stop the stack

# Stop containers but keep volumes (data is preserved)
npm run docker:prod:down

# Stop and delete all data (destructive — irreversible)
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml down -v

pgManage (DB admin UI)

pgManage is available at https://pgmanage.rentaldrivego.ma. To connect to the production database, add a connection inside pgManage with:

  • Host: localhost
  • Port: 5432
  • Database: rentaldrivego
  • Username: postgres
  • Password: value of POSTGRES_PASSWORD from .env.docker.production

Portainer

Portainer is available at https://portainer.rentaldrivego.ma.

Use this command to deploy or update it:

npm run docker:prod:start:portainer

Notes

  • The production image builds the whole monorepo once, then each service overrides its runtime command.
  • The dev compose file bind-mounts the repo and keeps node_modules in a named volume.
  • API_INTERNAL_URL is used for server-side container-to-container calls, while NEXT_PUBLIC_API_URL is used by the browser.
  • The Dockerfiles activate the repo's pinned npm@10.5.0 with corepack before install so container builds do not depend on the npm version bundled with the base image.
  • The dev compose stack stores Postgres data in postgres_dev_data and the bootstrap marker in postgres_bootstrap_state, so up --build does not reseed an existing local database.
  • If you need database schema updates inside Docker, run:
docker compose -f docker-compose.dev.yml run --rm migrate

If a cached base image still fails during npm ci, refresh it and rebuild without cache:

docker pull node:20-bookworm
docker compose -f docker-compose.dev.yml build --no-cache dashboard