# Instalar mbProduccion en un cliente

Escrito instalando el primero —`saraimasamadre`, en el servidor de **pruebas**
(`oci-mbinv`, 129.80.4.9), el 24-sep-2026—, así que lo de acá está corrido, no
imaginado.

> ## ⚠️ Hay DOS formas de instalar esto, y no es lo mismo cuál
>
> | | **demo / pruebas** | **cliente de producción** |
> |---|---|---|
> | Cómo corre | en contenedores | **sin Docker** — Apache y PHP nativos, como mbinv |
> | PHP 8.4 | lo trae la imagen | hay que instalarlo, y **por FPM** (ver abajo) |
> | Node | lo trae el contenedor | hay que instalarlo, y dejarlo como servicio |
> | Cómo se llega | IP y puerto, HTTP | un nombre de host con TLS |
> | Plantillas | `despliegue/docker-compose.yml.dist` + `vhost-demos.conf.dist` | `despliegue/vhost-produccion.conf.dist` + `mbproduccion-web.service.dist` |
>
> **Los pasos 2, 3, 6, 7, 8 y 9 son iguales en las dos.** Cambian el 1, el 4 y el 5.
>
> Y la parte de TLS no es cosmética en ninguna de las dos: el token de sesión viaja en
> la URL, así que sobre HTTP es un secreto de ocho horas viajando en claro, y con
> puertos publicados al lado del vhost queda una puerta sin candado junto a la que sí
> lo tiene.

## Lo que hace falta en el servidor

### En un demo (oci-mbinv)

Docker, y nada más. Ahí ya es la forma de correr todo lo que no es PHP 5.6: el
catálogo, mbhuella, ecomerce, taller y cxp viven en contenedores con Apache adelante.

| | |
|---|---|
| PHP del servidor | 5.6 y 8.2 — **ninguno sirve**, la API pide 8.4 y va en su imagen |
| Node | no hay, y no hace falta: el contenedor `node:22` lo trae |
| Salida a Internet | sí (Docker Hub, npm, packagist) — se necesita al construir |

### En producción, donde NO hay Docker

Esto es lo que hay hoy en el servidor de producción. Medido el 25-sep-2026, no supuesto:

```
/usr/bin/php     -> PHP 5.6.40      (el único php nativo)
apache2ctl -M    -> php5_module     (mbinv corre con mod_php 5.6)
proxy_fcgi       -> NO CARGADO
node / npm       -> no existen
```

De ahí salen las dos únicas cosas que hay que preparar, y las dos tocan a los ~163
sitios de clientes, así que van en ventana:

**1. PHP 8.4 tiene que entrar por FPM, no como módulo.** Apache no puede tener dos
`mod_php` a la vez, así que el 8.4 no puede ponerse al lado del 5.6 de mbinv. Entra
como `php8.4-fpm` y Apache le habla por `proxy_fcgi`. El repositorio de donde salió el
5.6 —ondrej/sury, ya configurado en la máquina— también tiene el 8.4.

```bash
sudo apt install php8.4-fpm php8.4-mysql php8.4-intl php8.4-zip php8.4-mbstring
sudo a2enmod proxy proxy_fcgi proxy_http headers
sudo systemctl enable --now php8.4-fpm
```

**2. La pantalla necesita Node corriendo.** `next start` es un servidor, no archivos que
Apache pueda servir solo, así que va como servicio de systemd
(`despliegue/mbproduccion-web.service.dist`).

> ⚠️ **Esto último tiene una alternativa que vale la pena decidir antes de instalar el
> primer cliente, no después.** Con `output: 'export'` en Next la pantalla queda en
> archivos estáticos que sirve el mismo Apache, y **Node desaparece de producción**:
> nada que instalar, nada que mantener arriba, nada que reiniciar en 163 servidores.
> Toda la pantalla ya es `"use client"` y habla con la API por `fetch`, así que no se
> pierde nada… salvo que `/ordenes/[id]` es una ruta dinámica y una ruta dinámica no se
> puede exportar: habría que moverla a `/ordenes?id=2`. Es un cambio chico de código y
> una decisión grande de operación. **Está sin decidir.**

## 1. Copiar el código

```bash
git clone git@github.com:macrobasegt/mbProduccion.git /var/www/html/<cliente>/produccion
cd /var/www/html/<cliente>/produccion

# SÓLO en un demo; en producción no hay Docker y este archivo no se usa.
cp despliegue/docker-compose.yml.dist docker-compose.yml   # y cambiarle los <>
```

Al lado de `mbinv` del mismo cliente. `vendor/` y `node_modules/` se instalan allá, no
se copian: se resuelven contra el PHP y el Node que van a correr el código —los del
contenedor en un demo, los de la máquina en producción—, no contra los nuestros.

Dos cosas de este paso:

- **Lo que lleva el nombre del cliente no se versiona; las plantillas sí.** El
  `docker-compose.yml`, el vhost y el `.service` llevan el nombre, los puertos y el host
  de esa instalación. Sus plantillas están en `despliegue/`.
- **`mbactualizar` no toca esto.** El actualizador de mbinv hace `git reset --hard`
  dentro de `/var/www/html/<cliente>/mbinv`, y esto vive un nivel arriba, en `produccion/`.
  Que sean hermanos y no anidados es lo que evita que una actualización de rutina se
  lleve por delante el módulo. Si alguien alguna vez lo mete adentro, se pierde en la
  siguiente actualización sin que nadie liste qué se perdió.

## 2. `api/.env.local`

No se versiona. Lo importante:

```dotenv
APP_ENV=prod
APP_SECRET=<openssl rand -hex 16>

# La base del cliente. mbProduccion vive DENTRO de ella, en tablas prod_*.
DATABASE_URL="mysql://<usuario>:<clave>@pruebas.db.interno:3306/<base>?serverVersion=8.0&charset=utf8mb4"

# La de temporales del mismo cliente, donde va la bitácora mensual.
TEMPORALES_DB=_temporales<cliente>

# La misma que va a quedar en parametros_backoffice.produccion_clave.
CLAVE_EMISION_TOKEN=<openssl rand -hex 32>

# En un cliente: el host del vhost, con https y SIN puerto. Es la dirección a la que
# el Back Office manda al usuario, así que si acá queda un http:// el token viaja en
# claro aunque el vhost esté bien puesto.
URL_FRONTEND=https://<host>

# Con el vhost de un solo host, la pantalla y la API quedan en el MISMO origen y el
# navegador nunca pide permiso de CORS. Se deja igual de estrecho por si alguien
# publica los puertos algún día: una regex abierta acá no avisa cuando empieza a hacer
# falta, simplemente deja entrar.
CORS_ALLOW_ORIGIN='^https://<host>$'
```

⚠️ Si la clave va con caracteres raros, **en la URL van percent-encoded**: una `@` en la
contraseña parte el `DATABASE_URL` en dos y el error que sale habla de un host que nadie
escribió.

## 3. Puertos

Elegirlos mirando qué hay libre, no de memoria. En oci-mbinv el 24-sep-2026 estaban
ocupados 3000-3003, 4000, 8080, 8090-8092, 8099 y 8100; quedaron **8093** para la API y
**3005** para la pantalla.

```bash
sudo ss -ltnp | awk '{print $4}' | grep -oE '[0-9]+$' | sort -nu
```

⚠️ En un cliente van publicados **sólo en loopback** — `"127.0.0.1:8093:80"`, no
`"8093:80"`. La plantilla de `despliegue/` ya viene así; es la línea que hay que
resistirse a «simplificar» cuando algo no conecta.

## 4. Levantar

### 4a. En un demo — con Docker

```bash
sudo docker compose build api
sudo docker compose run --rm --no-deps api composer install --no-interaction --optimize-autoloader
sudo docker compose run --rm --no-deps api php bin/console doctrine:migrations:migrate --no-interaction
sudo docker compose up -d api
sudo docker compose exec api chown -R www-data:www-data var
```

⚠️ Ese último `chown` **no es opcional**. Composer y la consola corren como root dentro
del contenedor y dejan `var/cache` y `var/log` de root; Apache sirve como `www-data` y
entonces el sitio contesta 500 diciendo que no puede escribir la caché. Hay que correrlo
después de cada `composer install` y de cada `cache:clear` hecho desde la consola.

Y la pantalla:

```bash
sudo docker compose run --rm --no-deps -e NODE_ENV=development web npm ci --include=dev
sudo docker compose run --rm --no-deps web npm run build
sudo docker compose up -d web
```

⚠️ `NEXT_PUBLIC_API_URL` **se hornea en el build**. Cambiarla en el `docker-compose.yml`
y reiniciar no hace nada: hay que volver a construir. Y el `NODE_ENV=development` del
`npm ci` es para que instale las dependencias de desarrollo, sin las cuales el build no
tiene ni TypeScript ni Tailwind.

### 4b. En producción — sin Docker

Lo mismo, pero con el PHP y el Node de la máquina. Da por hecho que ya se preparó el
servidor (php8.4-fpm, `proxy_fcgi`, Node) como dice *Lo que hace falta en el servidor*.

```bash
cd /var/www/html/<cliente>/produccion/api
php8.4 /usr/local/bin/composer install --no-dev --no-interaction --optimize-autoloader
php8.4 bin/console doctrine:migrations:migrate --no-interaction --env=prod
sudo chown -R www-data:www-data var
```

⚠️ **`php8.4`, con la versión escrita, y no `php`.** En esa máquina `php` es 5.6 y
Composer va a resolver las dependencias contra 5.6: instala versiones viejas de todo, o
falla con un mensaje sobre plataformas que no menciona la versión. Es el error más fácil
de cometer ahí, porque en cualquier otro servidor `php` sería el bueno.

⚠️ El `chown` **no es opcional**, por lo mismo que en el demo: Composer y la consola
corren como quien esté en la terminal y dejan `var/cache` y `var/log` suyos; Apache y
FPM sirven como `www-data` y el sitio contesta 500 diciendo que no puede escribir la
caché. Hay que correrlo después de cada `composer install` y de cada `cache:clear`.

Y la pantalla:

```bash
cd /var/www/html/<cliente>/produccion/web
npm ci --include=dev
NEXT_PUBLIC_API_URL="https://<host>" npm run build
sudo cp ../despliegue/mbproduccion-web.service.dist \
        /etc/systemd/system/mbproduccion-web-<cliente>.service
sudo nano /etc/systemd/system/mbproduccion-web-<cliente>.service   # los <>
sudo systemctl daemon-reload
sudo systemctl enable --now mbproduccion-web-<cliente>
```

⚠️ `NEXT_PUBLIC_API_URL` va **en la línea del build**, no en el `.service`. Ponerla en
el servicio no hace nada: ya quedó horneada en los archivos que `next start` va a servir.
Se comprueba mirando que el host esté adentro del JS construido:

```bash
grep -rl "<host>" web/.next/static/chunks/ | head -1
```

### Sobre la migración (las dos formas)

Crea nueve tablas en la base del cliente: las ocho `prod_*` y `prod_migraciones`, que es
el registro de migraciones renombrado a propósito para que **todo lo que este módulo mete
en la base del cliente se vea con un solo `SHOW TABLES LIKE 'prod\_%'`**.

## 5. El vhost con TLS

En la primera instalación de pruebas el módulo quedó en `http://129.80.4.9:8093` y
`http://129.80.4.9:3005`. **Eso no se repite en un cliente**: va detrás de un solo nombre
de host con certificado, y lo que esté escuchando —contenedores en un demo, FPM y el
servicio de Node en producción— sólo en `127.0.0.1`.

Tres cosas se arreglan de una sola vez, y conviene verlas juntas porque si sólo se busca
la primera es fácil dejar las otras dos abiertas:

| | |
|---|---|
| **El token deja de viajar en claro** | Al abrir el módulo, el Back Office manda al usuario con `?token=…`. Es un secreto de ocho horas. Sobre HTTP lo lee cualquiera en el camino. |
| **CORS deja de existir** | La pantalla y la API quedan en el mismo origen. No hay una regex que afinar ni que alguien pueda dejar en `.*`. |
| **Queda una sola puerta** | Sin puertos publicados no hay un camino alterno sin candado al lado del que sí lo tiene. Es el error que anula el certificado sin que nadie lo note. |

**Hay una plantilla por forma, y agarrar la equivocada no falla de una sino raro:**

| | plantilla | qué hace con `/api/` |
|---|---|---|
| demo | [`despliegue/vhost-demos.conf.dist`](../despliegue/vhost-demos.conf.dist) | `ProxyPass` al contenedor de PHP |
| producción | [`despliegue/vhost-produccion.conf.dist`](../despliegue/vhost-produccion.conf.dist) | `Alias` + `SetHandler` al socket de **php8.4-fpm** |

La de demo está copiada del vhost de `mbtaller.sistemasmb.com` que ya corre — no
inventada. La de producción sale de lo que se midió en esa máquina, y su nota más
importante es la del socket: **lleva la versión en el nombre**
(`/run/php/php8.4-fpm.sock`), y apuntarlo al genérico manda el código a un PHP que no es
8.4, con un error que habla de sintaxis y no de versión.

```bash
sudo cp despliegue/vhost-<demos|produccion>.conf.dist \
        /etc/apache2/sites-available/produccion-<cliente>.sistemasmb.com.conf
sudo nano /etc/apache2/sites-available/produccion-<cliente>.sistemasmb.com.conf  # los <>
sudo a2enmod proxy proxy_http proxy_fcgi headers   # proxy_fcgi sólo en producción
sudo a2ensite produccion-<cliente>.sistemasmb.com
sudo apache2ctl configtest && sudo systemctl reload apache2
sudo certbot --apache -d produccion-<cliente>.sistemasmb.com
```

⚠️ **Dejá el vhost completo ANTES de correr certbot.** Certbot escribe el de `:443`
aparte, como `…-le-ssl.conf`, copiando lo que encuentre. Un `ProxyPass` agregado después
hay que agregarlo en los dos, y olvidarlo produce lo más confuso posible: el módulo
funciona por HTTP y falla por HTTPS.

⚠️ **El `Redirect permanent` a https va comentado en la plantilla, y se descomenta hasta
después de certbot.** Antes, la redirección impide la validación del certificado.

⚠️ **Y `NEXT_PUBLIC_API_URL` se hornea en el build.** Si el host se decide después de
haber construido la pantalla, hay que volver a construirla (paso 4). Reiniciar el
contenedor no hace nada.

## 6. El primer administrador

Acá hay un huevo sin gallina: los permisos viven en `prod_permiso`, una base recién
migrada la tiene vacía, y **administrar es justo lo que hace falta para crear la primera
línea**. Se rompe desde la consola del servidor, una sola vez:

```bash
sudo docker compose exec api php bin/console produccion:administrador <usuario de maeusr>
```

A propósito **no existe una ruta HTTP que haga esto**: sería la única forma de volverse
administrador sin ser administrador. Que exija estar parado en el servidor es lo que
separa un paso de instalación de un agujero.

## 7. Revisar que quedó bien, antes de tocar mbinv

```bash
curl -s -H "X-Clave-Produccion: <la CLAVE_EMISION_TOKEN>" https://<host>/api/salud
```

Contesta qué encontró: la base, las ocho tablas de mbinv que usa, el trigger de
`existencias_tienda` y la base de temporales. Si algo falta, lo dice por nombre. En
saraimasamadre salió `sano: true` de una.

⚠️ **El detalle sólo sale con la clave.** Sin la cabecera contesta lo mismo —`sano` y
qué revisión falló— pero sin un solo dato del ambiente. La razón es el paso 5: esta
ruta queda en una dirección pública, y un diagnóstico que nombra la base del cliente,
la de temporales y el texto de los errores de MySQL es un mapa del servidor regalado a
quien pase. Sin cabecera sirve igual para un monitor de uptime, que es lo otro que uno
quiere de `/api/salud`.

Si contesta «Mandá la cabecera X-Clave-Produccion» **teniéndola puesta**, la clave del
`.env.local` y la que mandaste no son la misma — y entonces tampoco va a funcionar la
emisión de sesiones del paso 8.

## 8. Encenderlo en mbinv

Está en [INTEGRACION-MBINV.md](INTEGRACION-MBINV.md): *actualizar estructura* (de ahí
bajan las tres columnas, apagadas), el `UPDATE` de `parametros_backoffice` con la URL y
**la misma `CLAVE_EMISION_TOKEN`**, y la casilla «Producción (nuevo)» en Accesos.

Después de tocar el código de mbinv hay que botar su caché de producción —
**y como el usuario que le corresponde**:

```bash
sudo -u www-data rm -rf /var/www/html/<cliente>/mbinv/app/cache/prod
```

Hacerlo como root deja la carpeta de root cuando Symfony la regenere, y el sitio entero
contesta `Fatal error: Cache directory ... is not writable`. Es una de las formas más
rápidas de tumbar un cliente sin haber cambiado una línea de código.

## 9. Lo primero que se configura, ya en la pantalla

**Una línea de producción**, en *Líneas*: código, nombre, y las tres bodegas —materia
prima, producción, producto terminado—. Tienen que ser distintas: un traslado de una
bodega a sí misma no mueve nada y no avisa.

La pantalla avisa cuando el cliente tiene varias bodegas marcadas como materia prima en
el Back Office. En masamadreartisan son tres (#2 TIFFANY, #4 SPAZIO, #9 BODEGA DE
MATERIA PRIMA) y mbinv se queda con cualquiera; acá la línea elige explícitamente y la
orden congela esa elección al crearse.

## Actualizar una instalación que ya está puesta

Desde el 25-sep-2026 cada instalación es un checkout de verdad, así que actualizar es
`git pull`. **Pero `git pull` solo casi nunca alcanza**, y lo que falta no avisa: el
sitio sigue contestando 200 mostrando el código viejo.

```bash
cd /var/www/html/<cliente>/produccion
git pull --ff-only
git diff --stat HEAD@{1} HEAD        # ¿qué cambió realmente?
```

Y según **qué** cambió:

| si el pull tocó… | hay que correr |
|---|---|
| cualquier cosa de `api/` | `cache:clear` **y el `chown`** |
| `api/composer.lock` | `composer install` antes del `cache:clear` |
| `api/migrations/` | `doctrine:migrations:migrate` |
| cualquier cosa de `web/` | `npm run build` **y reiniciar**; si cambió `package-lock.json`, `npm ci` antes |

En un demo (Docker):

```bash
sudo docker compose exec api php bin/console cache:clear --env=prod
sudo docker compose exec api chown -R www-data:www-data var
sudo docker compose run --rm --no-deps web npm run build && sudo docker compose up -d web
```

⚠️ **Lo de la pantalla no es opcional ni evidente.** `next start` sirve lo que quedó en
`web/.next` del último build; un `git pull` cambia el código fuente y **no cambia nada de
lo que el usuario ve** hasta que se vuelve a construir. Y `NEXT_PUBLIC_API_URL` se hornea
en ese build, así que la variable tiene que estar puesta *al construir*, no al arrancar.

⚠️ **Y el `chown` de `var` tampoco.** `cache:clear` deja la caché del usuario que corrió
el comando; Apache sirve como `www-data` y el sitio contesta 500 diciendo que no puede
escribir la caché. Es la forma más rápida de tumbar una instalación con un comando que
parecía inocuo.

### Lo que un `git pull` NUNCA va a traer, y por qué está bien

`api/.env.local` y el `docker-compose.yml` no se versionan: son de esa instalación. Eso
significa que **una variable nueva no le llega sola a una instalación vieja** — que es
exactamente el modo de falla que tumbó 117 de 163 sitios de mbinv el 28-jul-2026, cuando
el repositorio empezó a usar un parámetro que `parameters.yml` no tenía.

Acá el piso existe y hay que respetarlo: **`api/.env` sí se versiona, y se carga ANTES
que `.env.local`.** Una variable nueva lleva su valor por omisión ahí, y la instalación
que no la tenga en su `.env.local` toma el del repositorio en vez de no arrancar.

> **Regla:** variable nueva → su valor por omisión va en `api/.env`, en el mismo commit.
> Ponerla sólo en el `.env.local` de la instalación donde se probó es sembrar la caída de
> todas las demás.

## Lo que quedó instalado en pruebas

| | |
|---|---|
| Sitio | `/var/www/html/saraimasamadre/produccion` — checkout de `main` |
| Código | mbProduccion `1fd2e45` · mbinv `0843b3cee` (rama `fet-T24507-Produccion`) |
| Base | `mbinvmasamadreartisan` en `pruebas.db.interno:3306` (MySQL 8.0.46) |
| Back Office del demo | http://129.80.4.9/saraimasamadre/mbinv/web/app_dev.php/ |
| API | http://129.80.4.9:8093 |
| Pantalla | http://129.80.4.9:3005 |
| Contenedores | `mbproduccion-api-saraimasamadre`, `mbproduccion-web-saraimasamadre` |
| Administrador | usuario 1 (`mb`) |
| Línea | `PAN` Panaderia — MP #9, producción #7, PT #8 |

**Es un demo, y por eso va así**: por IP, con los puertos publicados, sobre HTTP y
entrando por `app_dev.php`. Para un cliente de producción vale el paso 5 y la forma de
la derecha del cuadro de arriba.

### El vhost, probado ahí el 25-sep-2026

Quedó instalado `produccion-saraimasamadre.sistemasmb.com` en oci-mbinv, con el patrón
de `vhost-demos.conf.dist`, y se comprobó el flujo entero sobre TLS: `/` y `/api/salud`
en 200, emisión de sesión, `sesion/yo` y `ordenes` en 200, y el JS construido **sin un
solo rastro** de `129.80.4.9:8093` — o sea que todo sale por el mismo origen y CORS deja
de existir en vez de quedar «configurado».

⚠️ **Lo único que no se pudo probar fue el certificado de verdad**, y conviene saber por
qué antes de instalar un cliente: `*.sistemasmb.com` tiene un comodín que apunta a
**129.153.9.21**, que no es esta máquina. Los módulos que sí viven acá —`catalogo-qa`,
`mbtaller`, `pedidos-qa`— tienen cada uno su **registro A propio** apuntando a
129.80.4.9. Sin ese registro, certbot no puede validar, así que el `:443` se comprobó
con un certificado autofirmado: eso demuestra que el vhost enruta, no que el certificado
sirva. **Cada cliente nuevo necesita su registro A antes de correr certbot.**

Y la instalación quedó de vuelta en su forma de demo, porque sin ese registro el vhost
no se alcanza desde afuera y el demo se habría quedado a oscuras.
