GLP Cerca (5): operación y producto — despliegue, store y honestidad

Publicado el 05/07/2026

El código que funciona en tu máquina no es un producto. Un producto es algo que otra gente usa sin que tú estés delante, que se actualiza sin romperse, y que es honesto sobre lo que es. Este último artículo va de todo eso: lo que rodea al backend del artículo 3 y a la app del artículo 4 para convertirlos en algo publicable.

Despliegue: un solo binario, una sola variable

La filosofía de despliegue es la misma que la del resto del proyecto: la cosa más pequeña que funcione.

El backend se empaqueta en un Docker multi-etapa y se despliega en un VPS con Dokploy. La base de datos es externa (PostgreSQL gestionado), y el servicio se configura con una única variable de base de datos: DATABASE_URL.

environment:
  HOST: 0.0.0.0
  PORT: ${PORT:-3000}
  DATABASE_URL: ${DATABASE_URL:?define DATABASE_URL apuntando a tu PostgreSQL}
  INGESTA_INTERVALO_HORAS: ${INGESTA_INTERVALO_HORAS:-6}
  INGESTA_AL_ARRANCAR: ${INGESTA_AL_ARRANCAR:-true}
  INGESTA_PRODUCTO_ID: ${INGESTA_PRODUCTO_ID:-17}

Decisión: el binario lee solo DATABASE_URL, nunca POSTGRES_*. En producción defines esa URL y ya. En desarrollo, un docker-compose.dev.yml aparte levanta un PostgreSQL de conveniencia, y los POSTGRES_* viven solo ahí, como parámetros de ese contenedor. Separar "cómo me conecto a la BD" (una URL) de "cómo levanto una BD de juguete" (varios parámetros) evita que la configuración de producción herede ruido de desarrollo.

Detalles de operación que merecen mención:

  • Migraciones al arrancar. La app aplica sus migraciones sola al iniciar. No hay un paso manual que se pueda olvidar.
  • Ingesta al arranque (INGESTA_AL_ARRANCAR=true): a los pocos segundos de levantar, ya hay datos frescos. Y luego cada INGESTA_INTERVALO_HORAS (0 desactiva la tarea, útil si quieres ingerir desde fuera con cargo run -- ingestar).
  • Healthcheck de verdad. El docker compose tiene un healthcheck que pega a /health. Y /health no miente: consulta la bitácora de ingestas y marca stale si llevamos mucho sin refrescar. Un contenedor "arriba" sirviendo datos rancios es un fallo, y quiero que se vea como tal.

Observabilidad: que el sistema cuente qué le pasa

Ya lo toqué en el artículo 2, pero es un principio transversal: todo proceso desatendido tiene que dejar rastro.

  • tracing estructurado en todo el backend (con RUST_LOG configurable).
  • ingesta_log: cada ejecución, con su resultado, su error si lo hubo y sus contadores (nuevas, actualizadas, precios cambiados, descartadas).
  • Un backoffice mínimo (/admin) protegido con sesión por cookie y Argon2, que muestra salud, última ingesta y un botón de forzar ingesta. Es de lectura + operación: no edita datos de estaciones, porque esos los sobrescribe la ingesta y cualquier edición manual sería una mentira efímera.

Opinión: el admin no deja editar precios a propósito. La tentación de "lo corrijo a mano" es una trampa: el dato es de MINETUR, la siguiente ingesta lo pisaría, y mientras tanto estarías mostrando un número inventado. Mejor que el sistema no pueda hacer eso.

Versionado de la app: pensado para Google Play

Google Play tiene una regla inflexible: cada subida necesita un versionCode entero estrictamente creciente. Olvidarte de subirlo, o subirlo mal, es un rechazo seguro. Así que lo automaticé con un script (bump-version.sh) que deriva el versionCode del propio semver:

code = major*1_000_000 + minor*1_000 + patch
# 0.1.0 -> 1000 ; 1.2.3 -> 1002003
./bump-version.sh            # patch: 0.1.0 -> 0.1.1
./bump-version.sh minor      # 0.1.3 -> 0.2.0
./bump-version.sh 1.2.3      # versión exacta

Por qué derivarlo en vez de llevar un contador aparte: dos números que tienes que mantener sincronizados a mano acaban desincronizándose. Si el versionCode es una función pura del semver, es imposible que se contradigan. Subes la versión y el build number se actualiza solo, siempre coherente, siempre creciente.

El script toca tauri.conf.json y el tauri.properties de Android a la vez, e incluso contempla la diferencia entre el sed de macOS (BSD) y el de Linux, porque desarrollo en Mac pero el CI podría no ser Mac. Pequeños detalles que evitan el "en mi máquina funcionaba".

Firma y release: la llave que no se pierde

cargo tauri android build genera el APK y el AAB de producción (apuntando ya a https://glp.rexvalentia.es) y los firma con un keystore.

Aviso a mi yo futuro, escrito en el README: el keystore y su keystore.properties no se versionan y hay que guardar una copia segura. Son la llave permanente para actualizar la app en Play. Si la pierdes, no puedes volver a publicar actualizaciones de esa app, nunca. Esto no es una recomendación de estilo; es un punto de no retorno.

La honestidad como decisión de producto

Y llego a lo que, para mí, distingue este proyecto de un simple ejercicio técnico. Cómo te presentas ante el usuario es parte del producto.

GLP Cerca consume datos públicos de MINETUR. Eso me obliga a ser escrupuloso, y decidí serlo de forma agresiva, no en la letra pequeña:

  • No oficial, bien grande. La ficha de Play y la propia app dejan claro, desde la primera línea, que es un proyecto privado e independiente, no asociado al Gobierno ni al Ministerio ni a ninguna estación o marca. Usar datos públicos no te hace oficial, y fingirlo sería engañar.
  • Crédito a la fuente. Enlazo el catálogo de datos abiertos de MINETUR y el Geoportal de Gasolineras. El mérito del dato es de quien lo publica.
  • "Verifica en el surtidor". Los precios se actualizan periódicamente y puede haber desfases con la realidad del momento. Lo digo claramente: el precio de la app es una guía, el de la pistola es el que pagas.
  • Privacidad real, no declarada. Esto no es solo una frase en la política: es una propiedad de la arquitectura. Como conté en el artículo 4, la ubicación se usa en el dispositivo para pedir estaciones cercanas y nunca se almacena ni se asocia a un usuario en el servidor. La privacidad que prometo es la que el código hace cierta.

Opinión, y cierre de la serie: es tentador maquillar el origen de los datos, esconder el "no oficial" en un tooltip, o prometer una privacidad que tu arquitectura no respalda. No lo hice, y creo que es lo que diferencia una herramienta en la que confías de una que solo usas hasta que aparece otra. La confianza es la única funcionalidad que no puedes añadir en la versión 2.0.

Recapitulando la serie

  • 1 · Motivación: el mercado del GLP es opaco donde duele; el dato existe pero falta acceso en el momento de decidir. De ahí: backend que carga el peso + cliente tonto y rápido.
  • 2 · Datos e ingesta: normalizar en la frontera el feed de MINETUR (comas, TLS, duplicados) y el "histórico barato" que guarda solo los cambios de precio.
  • 3 · Backend en Rust: Axum + SeaORM, una capa de consulta compartida, y cercanía con bounding-box + haversine en vez de PostGIS.
  • 4 · La app: Tauri 2 + vanilla, color relativo al radio, una sola pulsación hasta la ruta y UX de conducción.
  • 5 · Operación y producto: despliegue de una sola variable, observabilidad honesta, versionado derivado y la honestidad como feature.

El hilo conductor de todas las decisiones fue el mismo: elegir la cosa más pequeña que resuelva el problema real, y ser honesto sobre lo que es. Tanto con el usuario como con el yo del futuro que tendrá que mantener esto.