Skip to content

OVH deployment and first persistent cutover

For the September redesign, start with the completed Step 7 cutover handoff. The redesign is live as of September 27, 2026. It supersedes this runbook's old /cementos route assumptions: both /guanza and /en/guanza reach the real storefront, with an empty Next basePath and /guanza asset prefix. Future releases use the normal deploy-ovh.sh plan / apply workflow, not the one-time cutover-redesign.sh. Do not repeat the historical disposable-preview reset.

Private release backups

The reusable scripts/capture-production-backup.sh --capture runs explicitly on the VPS. It captures private configuration, exact application images, a PostgreSQL dump and Garage snapshot/archive, and restore-checks the dump only inside an isolated temporary container. Copy and verify the private directory off-server before a release; never put it in Git or the Docs snapshot.

From WSL, using the existing ovh SSH configuration and only when a backup operation is authorized:

ssh ovh sh -s -- --capture < scripts/capture-production-backup.sh

This remains a manual backup, not a schedule or an atomic database/media snapshot. Full Garage recovery still needs its own rehearsal. Both verified off-server copies from the redesign cutover remain in operator-managed storage; the matching temporary VPS backup directories were deleted only after their checks completed. Never include the private copies or their contents in Git or the Docs snapshot.

This runbook replaces the disposable August preview with the Medusa-backed storefront and establishes the first persistent application state. System Nginx and Let's Encrypt remain installed directly on Ubuntu; the applications, PostgreSQL, Redis, and Garage run as containers.

Production topology

system Nginx :80/:443
  -> 127.0.0.1:3000  portfolio
  -> 127.0.0.1:3100  Medusa DTC storefront at /guanza and /en/guanza
  -> 127.0.0.1:9000  Medusa API and built-in Admin
  -> 127.0.0.1:3902  Garage product media

private Docker network
  -> PostgreSQL 17
  -> Redis 7.4
  -> Garage S3 API

The Compose project is named andreaweb. Its PostgreSQL, Redis, and Garage named volumes survive image rebuilds, container replacement, release-directory changes, and ordinary docker compose down commands.

One-time replacement of the disposable preview

The August preview used the separate Compose project name andreaweb-preview. Destroy it only immediately before deploying the prepared replacement release:

ssh ovh
cd /opt/andreaweb/current
docker compose --env-file /opt/andreaweb/shared/preview.env \
  -f compose.prod.yaml down --volumes --remove-orphans
rm -f /opt/andreaweb/shared/preview-admin-invite.txt
exit

That deliberately deletes the preview database, accounts, invitations, inventory, carts, Redis data, and uploaded media. It does not alter Nginx, the Let's Encrypt certificate, DNS, or committed release directories. The old preview.env may be retained temporarily for audit or removed after the new deployment is healthy; it is never reused by the new Compose project.

Do not repeat this reset after the first persistent deployment.

Plan and deploy a committed release

From /home/justin/repos/AndreaWeb in WSL:

./scripts/deploy-ovh.sh plan

The plan is read-only. It compares the candidate commit with the separately recorded portfolio, storefront, and Medusa commits on the VPS. Documentation, tests, and deployment-tool-only changes produce no runtime action. Application changes produce an exact command such as:

./scripts/deploy-ovh.sh apply \
  --commit 0123456789abcdef0123456789abcdef01234567 \
  --target storefront

Do not shorten or substitute the reviewed commit. apply refuses a dirty worktree, uploads that committed tree as an immutable release, builds only the selected application image, replaces only that stateless container, waits for health, and records the deployed commit. Supported targets are portfolio, storefront, medusa, and all-apps. A Medusa plan adds --migrate only when the changed paths include a migration. System Nginx is not rebuilt or reloaded.

On a genuinely new environment, explicitly bootstrap all services:

commit=$(git rev-parse HEAD)
./scripts/deploy-ovh.sh apply --commit "$commit" --target bootstrap

The first bootstrap creates a private mode-0600 environment at:

/opt/andreaweb/shared/production.env

Bootstrap builds the three application images, starts the stateful services, runs database migrations, reads the publishable key into the private environment, and checks all loopback services. It is deliberately non-destructive: it never removes a volume, resets application data, or overwrites an existing private environment. initialize-production.sh remains as a compatibility alias for bootstrap-production.sh.

Normal releases do not bootstrap Garage, seed products, or import media. Starter data is explicit maintenance:

ssh ovh
cd /opt/andreaweb/current
./scripts/maintain-production-catalog.sh \
  /opt/andreaweb/shared/production.env seed-starter-catalog
./scripts/maintain-production-catalog.sh \
  /opt/andreaweb/shared/production.env import-starter-media

The catalog command creates only missing starter handles and their inventory; it does not replace Admin-created products. The media command uploads bundled starter images only for starter products without images. Neither is a generic database restore or a required part of deployment.

The generated WhatsApp destination is blank. Before the cutover, set the approved international-digits-only value in production.env; it remains a server-only storefront setting. The public inquiry address defaults to cementos@andreazambrano.co. After changing a server-only storefront value, recreate the existing container without publishing or rebuilding a release:

./scripts/operate-ovh.sh recreate storefront

This reuses the exact image already running. It does not run migrations or touch PostgreSQL, Redis, Garage, or their volumes. Public NEXT_PUBLIC_* values are compiled into the storefront image and therefore require a storefront release instead of a recreate.

Use ./scripts/operate-ovh.sh status for a read-only remote Compose status. The recreate command accepts only portfolio, storefront, or medusa; stateful-service recreation is intentionally not exposed by this convenience tool.

Deployment command selection

Change Command selected
Storefront source or compiled public setting plan, then targeted apply --target storefront
Portfolio source plan, then targeted apply --target portfolio
Medusa source without migrations plan, then targeted apply --target medusa
Medusa migration plan, then targeted Medusa apply with --migrate
Shared production/Compose change conservative all-apps apply
Server-only environment value edit the private VPS environment, then operate-ovh.sh recreate SERVICE
Documentation, tests, or deployment tools only no runtime deployment
New or repaired full environment explicit apply --target bootstrap

The /opt/andreaweb/current symlink identifies the newest uploaded source release. Runtime truth is stored per application under /opt/andreaweb/shared/deployments/*.commit, because targeted deployments can leave the three applications on different commits.

Apply runtime-only Compose configuration

Use the guarded runtime-config target for a shared Compose change that should take effect without releasing new application images, such as Docker logging options:

./scripts/deploy-ovh.sh apply \
  --commit FULL_REVIEWED_COMMIT \
  --target runtime-config

This target reads image references from the currently running web, storefront, and medusa containers and pins those references for the Compose operation. It validates the resolved Compose model quietly, checks the expected named volumes, then recreates postgres, redis, and garage, followed by medusa, then storefront and web, waiting for health at each stage. It verifies the named volume identities, health, and each long-running container's actual logging driver/options afterward. It does not build or pull images, run migrations, alter application deployment markers, or start the one-shot garage-init service.

The production Compose file has a shared json-file policy (max-size: "10m", max-file: "3") for all seven services, including garage-init. Compose config validation checks the complete service model before the runtime target changes a container. Existing containers retain their original Docker logging settings until recreated; changing the file alone does not apply the new limits. Preserve the named volumes during this operation. Never use down --volumes or volume pruning to apply runtime configuration.

The September 28, 2026 runtime-configuration release used this target at exact commit e4147a72374c45e301a03bf35bfc760858e6297d, uploaded to /opt/andreaweb/releases/e4147a72374c. It recreated the six long-running services with the same image identities. All six were healthy for about an hour afterward, their logging options were verified with docker inspect, and all four named volumes retained the same names and 2026-08-25 creation dates (about 105.6 MB total). The garage-init configuration was validated but the one-shot service was not rerun. Loopback and public bilingual release checks passed for portfolio, storefront, API/Admin, and a real product-media read.

That release also applied these memory/PID limits: web 384 MiB/128, storefront 512 MiB/128, medusa 1,024 MiB/256, postgres 512 MiB/128, redis 256 MiB/64, garage 256 MiB/128, and garage-init 128 MiB/64. The six long-running memory limits total 2,944 MiB. Redis remains noeviction with maxmemory=0; no CPU caps or arbitrary Node heap flags were added. At final verification all six were healthy, OOMKilled was false, and restart count was 0. After about an hour, usage was 145.3, 126.3, 271.9, 29.97, 6.449, and 9.023 MiB respectively for web, storefront, medusa, postgres, redis, and garage. Host memory was 3.7 GiB total, 1.1 GiB used, and 2.6 GiB available; swap was active with about 7 MiB used. Treat this as a short, light-load observation, not a workload capacity guarantee.

Host log rotation and emergency swap

As of September 28, 2026, the duplicate obsolete /etc/logrotate.d/cloud-init file has been removed; the active cloud-init-base package configuration remains. The normal logrotate service completed successfully with exit status 0, its failed state was cleared, and logrotate.timer is enabled and active on its daily schedule. Nginx rotation ownership and reopen behavior were preserved.

The host has one 1-GiB /swapfile, mode 0600, owned by root, with the persistent entry /swapfile none swap sw 0 0 in /etc/fstab. The active swapfile.swap unit is generated by systemd from fstab; vm.swappiness=10 is set in /etc/sysctl.d/99-andreaweb-emergency-swap.conf. findmnt --verify --verbose reported zero parse errors and zero errors. Its warnings were the existing root filesystem passno=0 recommendation and the expected regular-file swap source. Systemd was reloaded to regenerate the unit; no reboot was used. Swap is an emergency buffer and not additional application capacity.

Final production verification and retention record (2026-09-28)

Both exact cutover backup directories on the VPS were deleted only after backup, image, database-integrity, Garage-inventory, and off-server-copy checks passed. Both verified off-server copies remain. This is intentional off-server-only custody; the read-only host audit reports a missing VPS backup as a warning and does not send alerts. The copies are still manual point-in-time backups, not a scheduled/atomic backup system, and a full Garage restore rehearsal remains open.

The 2.016 GB Docker build cache was pruned only after it was verified idle and reclaimable. Docker images occupied 5.126 GB total; 3.057 GB was reclaimable before the cleanup and 1.955 GB remains reclaimable as intentional known-good rollback. Four obsolete release directories were removed. Four retained release directories are: runtime configuration e4147a72374c, deployed web/storefront source 0f56ca8156ef, backend rollback source aebdc81d8bda, and previous configuration c5c3dfaa8058. The same four named volumes, created 2026-08-25 and totaling about 105.6 MB, were preserved.

The legacy kestrel-wedding.service and /var/aspnetcore/wedding were removed after a dependency audit. Six .NET 2.2 packages were purged, the unowned 1.1 GB /usr/share/dotnet tree was removed, and apt autoremove removed the four exact orphan libraries. No failed systemd units remained. The duplicate /etc/logrotate.d/cloud-init file was absent at final check; recheck after package upgrades. The root filesystem's passno=0 entry was reviewed as a cloud-image entry and left unchanged. The local SSH client ovh alias uses ServerAliveInterval 30 and ServerAliveCountMax 6; this is an operator-side client setting.

The final host had 39G root storage, 15G used, 24G available (39%), and 13% inode use. Memory was 3.7 GiB total, 1.1 GiB used, and 2.6 GiB available. The 1 GiB swap was active with about 7 MiB used; vm.swappiness=10 is persistent. Journald uses the root-owned SystemMaxUse=512M, SystemKeepFree=2G, and MaxRetentionSec=21day drop-in; usage fell from 595.2 MB before rotation/vacuum to 490.5 MB afterward. Logrotate's last service status was 0 and its daily timer is enabled/active. Nginx, Docker, and journald were active.

The scrubbed 24-hour Medusa/Garage log review found 58 Medusa lines and 0 Garage lines, with no current flood evidence. Container logs remain bounded by the Compose 10m per file and 3 files policy. This audit is a point-in-time check; ongoing monitoring and external alerts are future work.

The repository's read-only production audit script can be streamed to the host from the WSL checkout:

ssh ovh 'sudo bash -s' < scripts/audit-production-host.sh

It reports filesystem/inode, memory/swap, Docker container health and configured limits, Docker usage and volume names, logrotate/journald state, backup age, and release pointer. It does not modify the host, inspect backup contents, or send alerts. Review its warning for absent on-VPS backups in light of the intentional off-server-only custody, and verify the retained copies separately.

Historical first-storefront Nginx change

The original /cementos routing instructions are superseded by the completed redesign cutover. Do not apply the old route change. The live contract sends portfolio routes such as / and /arquitectura to port 3000, and /guanza and /en/guanza to port 3100; see the cutover record. Any future live Nginx edit must be additive, backed up, validated with nginx -t, and authorized as part of its release plan.

Create the first administrator

After Medusa is healthy, create the first invitation without needing an existing user:

cd /opt/andreaweb/current
./scripts/create-production-admin-invite.sh \
  /opt/andreaweb/shared/production.env \
  andrea@andreazambrano.co \
  /opt/andreaweb/shared/production-admin-invite.txt

Copy only the HTTPS acceptance URL from the mode-0600 file to Andrea. Delete the file after acceptance. Running the command again before acceptance replaces the pending token; after acceptance it safely reports that the user exists.

Verify the release

On the VPS:

cd /opt/andreaweb/current
docker compose --env-file /opt/andreaweb/shared/production.env \
  -f compose.prod.yaml ps
docker compose --env-file /opt/andreaweb/shared/production.env \
  -f compose.prod.yaml logs --tail=100 web storefront medusa
curl --fail http://127.0.0.1:3000/
curl --fail http://127.0.0.1:3100/guanza
curl --fail http://127.0.0.1:9000/health

Then verify externally:

  • https://andreazambrano.co/
  • https://andreazambrano.co/guanza
  • https://api.andreazambrano.co/health
  • https://admin.andreazambrano.co/app
  • one product image under https://media.andreazambrano.co

Keep the current X-Robots-Tag and robots.txt blocking in place while access is limited to review. In Admin, create an unknown product with multiple images, a COP price, managed inventory, and a quantity at Inventario principal; then confirm it appears in the storefront after refresh.

Persistence rule after cutover

Do not use down --volumes for normal operations. Rebuilds and deployments must preserve named volumes. Before the data is treated as durable production state, complete automated backups, restore testing, monitoring, disk-capacity alerts, and a migration-aware rollback procedure from the roadmap.