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:
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:
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:
The first bootstrap creates a private mode-0600 environment at:
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:
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:
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:
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/guanzahttps://api.andreazambrano.co/healthhttps://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.