Instalación: VPS con nginx, PHP-FPM y MariaDB, y con Docker

Por: Artiko
fossbillinginstalacionnginxphp-fpmmariadbdockervps

Instalación: VPS con nginx, PHP-FPM y MariaDB, y con Docker

En el capítulo 1 quedó claro de dónde viene FOSSBilling y por qué 0.8.5 es el baseline mínimo que el propio proyecto declara: 32 advisories publicados en 2026, cuatro de ellos críticos, y todo lo anterior a 0.8.0 con bypass de autenticación y RCE conocidos. Instalar la versión correcta no es una preferencia, es el primer control de seguridad.

El problema es que la guía de instalación oficial tiene al menos cuatro puntos que no funcionan tal cual están escritos. El enlace de descarga «stable» devuelve un fichero de 9 bytes. La lista de extensiones requeridas de la documentación no coincide con la que comprueba el código, y la que falta bloquea el botón de instalar. El bloque nginx publicado trae un placeholder que no es una ruta real y una directiva deprecada desde nginx 1.25.1. Y el docker-compose.yml oficial monta un volumen que tapa el código de la imagen, con lo que subir el tag no actualiza absolutamente nada.

Este capítulo recorre las dos rutas con cada uno de esos puntos resuelto y explicado. El escenario de referencia es un VPS Debian o Ubuntu limpio, dominio billing.example.com, raíz /var/www/fossbilling y usuario del servidor web www-data. Al terminar tendrás una instancia 0.8.5 sirviendo por HTTPS, con /install borrado, /data y /vendor inaccesibles por web y el cron latiendo cada cinco minutos.

Una restricción estructural antes de empezar: FOSSBilling debe vivir en la raíz de documentos de su propio dominio o subdominio. Las instalaciones en subcarpeta tipo example.com/billing están deprecadas desde 0.4.2 y no funcionan. El instalador las detecta con isSubfolder(), que devuelve substr_count(URL_INSTALL, '/') > 4, y bloquea la instalación. No hay workaround.

flowchart TD
    A["VPS limpio Debian o Ubuntu"] --> C["Instalar nginx"]
    C --> D["Instalar MariaDB y endurecerla"]
    D --> E["Crear base y usuario dedicado en utf8mb4"]
    E --> F{"PHP 8.3 o superior en repos base?"}
    F -->|No| G["Anadir Sury o el PPA ondrej/php"]
    F -->|Si| H["Instalar PHP-FPM y extensiones"]
    G --> H
    H --> I["Descargar el asset real y descomprimir"]
    I --> J["chown www-data y permisos 755/644"]
    J --> K["Crear el server block de nginx"]
    K --> L["Certificado TLS con certbot"]
    L --> M["Lanzar el asistente web"]
    M --> N["Verificar que /install desaparecio"]
    N --> O["Instalar el cron cada 5 minutos"]

Requisitos oficiales frente a los que comprueba el código

Esto es lo que declara Requirements::$php_reqs en src/library/FOSSBilling/Requirements.php, tag 0.8.5:

public array $php_reqs = [
    'required_extensions' => [
        'curl', 'intl', 'openssl', 'pdo_mysql', 'xml',
        'dom', 'iconv', 'json', 'zlib', 'gd',
    ],
    'min_version' => '8.3',
];

Gotcha: gd es obligatorio aunque la documentación lo liste como recomendado. Requirements::checkCompat() recorre required_extensions y, si gd no está cargada, pone $this->isOk = false. Eso hace can_install = false y el instalador deshabilita el botón de instalar. Es el fallo más frecuente y el más confuso, porque seguiste la documentación al pie de la letra.

Las sugeridas en el código son mbstring, opcache, imagick, bz2, simplexml y xml; bz2 no aparece en la tabla de la documentación. El chequeo de opcache es especial: no basta con extension_loaded, llama a opcache_get_status() y comprueba $status['opcache_enabled'], así que OPcache instalada pero desactivada cuenta como ausente.

RequisitoDocumentación oficialCódigo de 0.8.5
PHP8.3, 8.4 y 8.5 soportadasmin_version = '8.3'
Extensiones requeridascurl, dom, iconv, intl, json, openssl, pdo_mysql, xml, zliblas mismas más gd
Sugeridasmbstring, opcache, imagick o gd, simplexmlmbstring, opcache, imagick, bz2, simplexml, xml
Base de datosMySQL >= 8.4, MariaDB >= 11.4, utf8mb4 / utf8mb4_unicode_ciel instalador la crea en utf8 / utf8_general_ci
Sistema operativono se mencionaos_ok = false si PHP_OS empieza por WIN

Los privilegios que la documentación exige para el usuario de base de datos son exactamente SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX y ALTER.

El constructor de Requirements exige además que data/cache, data/log y data/uploads sean escribibles por el usuario de PHP, y que config.php pueda crearse: si no existe, prueba a escribirlo con dumpFile() y luego borra el fichero de prueba. Si no puede, aborta con el código 101 y el mensaje “Configuration file is not writable or does not exist.”

Sobre php.ini, la documentación pide memory_limit de al menos 64M, deja max_execution_time en 30 segundos y sugiere 45-60 en hosting compartido, y recomienda allow_url_fopen = On si vas a incrustar imágenes remotas en PDFs o correos.

Matriz distribución a versión de PHP y cómo añadir Sury o el PPA de Ondřej

DistribuciónPHP en repos base¿Sirve para 0.8.5?Acción
Debian 13 «trixie»php8.4Sí, directoapt install php8.4-fpm ...
Debian 12 «bookworm»php8.2NoRepositorio Sury
Debian 11 «bullseye»php7.4NoSury
Ubuntu 24.04 «noble»php8.3Sí, directoapt install php8.3-fpm ...
Ubuntu 22.04 «jammy»php8.1NoPPA ondrej/php

Base del sistema y nginx, común a todos los casos:

sudo apt update && sudo apt -y upgrade
sudo apt -y install nginx curl unzip ca-certificates lsb-release apt-transport-https software-properties-common
sudo systemctl enable --now nginx

Repositorio de terceros cuando la distribución se queda corta:

# Debian 12 o cualquier Debian: repositorio Sury
sudo curl -fsSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg
echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" \
  | sudo tee /etc/apt/sources.list.d/php.list
sudo apt update

# Ubuntu 22.04: PPA de Ondrej Sury
sudo add-apt-repository -y ppa:ondrej/php
sudo apt update

A partir de aquí los nombres de paquete son idénticos; lo único que cambia es el número de versión.

MariaDB: base de datos, usuario dedicado y por qué crearla tú en utf8mb4

sudo apt -y install mariadb-server
sudo systemctl enable --now mariadb
sudo mariadb-secure-installation

En MariaDB moderna el script de endurecimiento se llama mariadb-secure-installation; mysql_secure_installation sigue existiendo como alias. Responde: fijar contraseña de root o dejar unix_socket, eliminar usuarios anónimos, prohibir login remoto de root, borrar la base test y recargar privilegios.

La documentación solo dice “Create a new MariaDB/MySQL database and a dedicated user for this installation”. El SQL concreto, alineado con el juego de caracteres que ella misma recomienda:

CREATE DATABASE fossbilling
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

CREATE USER 'fossbilling'@'localhost'
  IDENTIFIED BY 'UnaContrasenaLargaYAleatoria';

GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER
  ON fossbilling.* TO 'fossbilling'@'localhost';

FLUSH PRIVILEGES;

Genera la contraseña fuera de la sesión SQL, comprueba la conexión y deja el motor escuchando solo en loopback:

openssl rand -base64 32
mariadb -u fossbilling -p -h 127.0.0.1 fossbilling -e "SELECT 1;"
sudo sed -i 's/^bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mariadb.conf.d/50-server.cnf
sudo systemctl restart mariadb && sudo ss -tlnp | grep 3306   # solo 127.0.0.1:3306

Advertencia: crea tú la base, no dejes que la cree el instalador. En src/install/install.php::connectDatabase() el instalador se conecta sin seleccionar base de datos y ejecuta CREATE DATABASE <nombre> CHARACTER SET utf8 COLLATE utf8_general_ci; dentro de un try/catch que ignora el fallo en silencio.

Uno. Si le das un usuario con privilegio CREATE, puede crear la base solo, pero la crea en utf8 y utf8_general_ci, contradiciendo la recomendación de la propia documentación. Y 0.8.0 migró el esquema precisamente de utf8 a utf8mb4 mediante el patcher.

Dos. Si la creas tú antes en utf8mb4, el CREATE DATABASE interno falla en silencio y se respeta tu juego de caracteres. Esa es la razón exacta de este orden de pasos.

El instalador fija además en la sesión SET NAMES "utf8", SET CHARACTER SET utf8, SET SESSION interactive_timeout = 28800 y SET SESSION wait_timeout = 28800, y escapa el nombre de la base con backticks vía quoteMysqlIdentifier().

PHP-FPM: extensiones, php.ini y verificación antes de abrir el navegador

sudo apt -y install \
  php8.3-fpm php8.3-cli php8.3-common \
  php8.3-curl php8.3-intl php8.3-mysql php8.3-xml php8.3-mbstring \
  php8.3-gd php8.3-zip php8.3-bz2 php8.3-opcache
sudo systemctl enable --now php8.3-fpm

En Debian 13 la lista es idéntica cambiando php8.3 por php8.4. Tres notas de empaquetado que evitan perseguir fantasmas:

Uno. dom, iconv, json, openssl, zlib y simplexml van dentro de phpX.Y-common o del binario base; no existen como paquetes separados. Dos. pdo_mysql lo aporta phpX.Y-mysql. Tres. imagick, alternativa opcional a gd para PDFs, es phpX.Y-imagick y arrastra el paquete imagemagick del sistema.

Verifica las extensiones antes de abrir el navegador. Este bucle recorre exactamente la lista required_extensions del código:

for e in curl intl openssl pdo_mysql xml dom iconv json zlib gd; do
  php -m | grep -qix "$e" && echo "OK    $e" || echo "FALTA $e"
done
php-fpm8.3 -m | sort | diff <(php -m | sort) -   # el CLI y FPM pueden diferir

Gotcha: el binario CLI y el módulo FPM pueden tener conjuntos de extensiones distintos. php -m te dice lo que ve la línea de comandos, no lo que verá el sitio. De ahí el diff de la última línea.

Ajustes de /etc/php/8.3/fpm/php.ini para un servidor de facturación:

memory_limit = 256M
max_execution_time = 60
allow_url_fopen = On
date.timezone = UTC
upload_max_filesize = 16M
post_max_size = 16M
opcache.enable = 1
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.save_comments = 1

256M es un valor cómodo con generación de PDFs y varios módulos activos, muy por encima del mínimo de 64M. Deja date.timezone en UTC: la facturación y el cron son mucho más fáciles de razonar así, y el propio instalador arranca con date_default_timezone_set('UTC') de forma incondicional.

Advertencia sobre OPcache: no pongas save_comments=0, porque FOSSBilling usa Doctrine y atributos de PHP y romperías las librerías que leen anotaciones. Y deja validate_timestamps=1: con 0 ganas rendimiento pero rompes el actualizador integrado, que escribe ficheros nuevos en caliente. Tras cambiar extensiones o php.ini, reinicia PHP-FPM con sudo systemctl restart php8.3-fpm; recargar nginx no basta.

Descargar el release: el bug de /downloads/stable y los métodos fiables

Advertencia: el enlace de descarga que da la documentación oficial devuelve 404. Comprobado el 9 de agosto de 2026 con curl -sL contra https://fossbilling.org/downloads/stable:

final = https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.5/FOSSBilling.zip
code  = 404
size  = 9 bytes  (cuerpo: "Not Found" en texto plano)

La causa raíz es un cambio de nombre de artefacto entre ramas: en 0.7.1 y 0.7.2 el asset se llamaba FOSSBilling.zip, y desde 0.8.0 se llama FOSSBilling-<versión>.zip. El redirector sigue apuntando al nombre antiguo. El resultado es que curl ... --output FOSSBilling.zip deja un fichero de 9 bytes y el unzip posterior falla con un error incomprensible. Todas las guías de comunidad que circulan traen ese patrón roto copiado tal cual. El asset real de 0.8.5 es FOSSBilling-0.8.5.zip y pesa 31.069.155 bytes.

cd /tmp
VER=0.8.5
# Metodo directo, con el nombre real del asset
curl -fL -o FOSSBilling.zip \
  "https://github.com/FOSSBilling/FOSSBilling/releases/download/${VER}/FOSSBilling-${VER}.zip"

# Metodo robusto, inmune a futuros cambios de nombre
URL=$(curl -fsSL https://api.github.com/repos/FOSSBilling/FOSSBilling/releases/latest \
      | grep -o '"browser_download_url": *"[^"]*\.zip"' | head -1 | cut -d'"' -f4)
curl -fL -o FOSSBilling.zip "$URL"

Verifica siempre que bajó un ZIP de verdad antes de descomprimir. Este paso detecta el 404 en un segundo:

file FOSSBilling.zip                                    # debe decir: Zip archive data
unzip -t FOSSBilling.zip > /dev/null && echo "ZIP integro"

Limpia la raíz de documentos de ficheros de relleno del panel de control —índices por defecto, .htaccess de ejemplo— antes de descomprimir, porque interfieren con el enrutado. Es el paso 1 literal de la guía oficial.

sudo mkdir -p /var/www/fossbilling
sudo unzip -q /tmp/FOSSBilling.zip -d /var/www/fossbilling

El árbol que queda:

/var/www/fossbilling/
  data/       runtime: cache, log y uploads. Nunca accesible por web
  install/    asistente web, se borra tras instalar
  library/    framework: FOSSBilling\* y las clases legadas Box_*
  locale/     traducciones
  modules/    los modulos del core
  public/     assets compartidos: assets, gateways, branding
  themes/     admin_default y huraga
  vendor/     dependencias de Composer. Bloqueada por web
  config-sample.php  console.php  cron.php  di.php
  index.php   ipn.php   load.php   .htaccess   rewrite-missing.html

config.php no viene en el ZIP: lo genera el asistente. La referencia exhaustiva de ese fichero y de las constantes PATH_* que define load.php está en el capítulo 3.

Permisos, propietario y el usuario real bajo el que corre PHP

La documentación es explícita: “Usually 755 is correct for folders and 644 for most PHP files, but security-sensitive files such as config.php may require stricter permissions (for example 600 or 640)”. Sus comandos son find . -type d -exec chmod 755 {} \; y find . -type f -exec chmod 644 {} \;; usar + en vez de \; hace un exec por lote en lugar de uno por fichero, que sobre casi 30 MiB se nota.

cd /var/www/fossbilling
sudo find . -type d -exec chmod 755 {} +
sudo find . -type f -exec chmod 644 {} +
sudo chown -R www-data:www-data /var/www/fossbilling
sudo mkdir -p data/cache data/log data/uploads
sudo chown -R www-data:www-data data && sudo chmod -R 775 data

Los bits correctos no arreglan nada si el dueño es incorrecto. Y antes de instalar config.php no existe, pero su directorio padre debe permitir crearlo. Dos formas de resolverlo:

# Opcion A, recomendada: permitir la creacion y endurecer tras el asistente
sudo chown www-data:www-data /var/www/fossbilling
sudo chmod 640 /var/www/fossbilling/config.php     # despues de instalar

# Opcion B: pre-crear el fichero vacio y escribible
sudo -u www-data touch /var/www/fossbilling/config.php && sudo chmod 664 /var/www/fossbilling/config.php

Averiguar bajo qué usuario corre PHP de verdad importa más de lo que parece, porque en paneles de control y en configuraciones con un pool por sitio no siempre es www-data:

grep -E '^(user|group)' /etc/php/8.3/fpm/pool.d/www.conf
ps -o user= -C php-fpm8.3 | sort -u

En RHEL y derivados hay una capa más, y es el clásico «los permisos están bien y sigue fallando»: SELinux. La documentación cubre el caso con semanage fcontext -a -t httpd_sys_content_t, restorecon -Rv y los booleanos httpd_can_network_connect, httpd_can_sendmail y httpd_can_network_connect_db, y marca explícitamente como “Discouraged” la opción de desactivarlo.

El bloque nginx oficial: análisis directiva a directiva y sus siete problemas

La postura oficial es que Apache no necesita configuración específica y que las plantillas para el resto de servidores “should be treated as starting points”. Esta es la lectura de la plantilla de nginx publicada:

Directiva o bloqueFunción
server { listen 80; ... return 301 https://... }Redirección permanente a HTTPS. Complementa, no sustituye, a security.force_https.
ssl_stapling on; ssl_stapling_verify on;OCSP stapling. Requiere ssl_trusted_certificate, que la plantilla no incluye.
set $root_path %%SOURCE_PATH%%;Variable reutilizada por root y por el bloque de assets estáticos.
try_files $uri $uri/ @rewrite;Núcleo del enrutado: si el fichero existe se sirve, si no salta al named location.
sendfile off;Evita corrupción de caché en sistemas de ficheros virtualizados como NFS.
location ~* .(ini|sh|inc|bak|twig|sql)$Bloquea configuración, scripts, includes, backups, plantillas Twig y volcados SQL.
location ^~ /vendor/ { return 403; }Bloquea las dependencias de Composer.
location = /config.php { return 403; }Match exacto que impide descargar la configuración.
location ~ /\.(?!well-known\/)Bloquea ficheros ocultos salvo /.well-known/, para que funcione el reto ACME.
location ^~ /data/ { return 403; }Crítico: caché, logs y uploads en tiempo de ejecución.
location @rewriterewrite ^/page/(.*)$ /index.php?_url=/custompages/$1; y rewrite ^/(.*)$ /index.php?_url=/$1;
location ~ \.php$Pasa PHP a FPM; fastcgi_intercept_errors on deja que nginx sustituya los errores.
location ~* ^/(css|img|js|flv|swf|download)/(.+)$Assets estáticos con expires off.

Y estos son los siete problemas verificados de esa plantilla:

Uno. La indentación es engañosa pero las llaves están balanceadas. El bloque location ~ \.php$ parece no cerrarse y el de assets parece anidado dentro de él. Contando llaves el fichero es válido y nginx -t pasa. Es mala indentación heredada, no un bug.

Dos. fastcgi_pass unix:/run/php/phpx.x-fpm.sock; es un placeholder. phpx.x no es una ruta real; la propia plantilla lo advierte en comentarios. Sustitúyelo por php8.3-fpm.sock o php8.4-fpm.sock y verifica con ls -l /run/php/.

Tres. El punto sin escapar en el regex de ficheros sensibles. ~* .(ini|sh|...)$ usa . como «cualquier carácter». Bloquea de más, no de menos, así que no es un agujero, pero lo correcto es ~* \.(ini|sh|...)$.

Cuatro. listen 443 ssl http2; está deprecado desde nginx 1.25.1. nginx emite “the listen … http2 directive is deprecated, use the http2 directive instead”. La forma actual es listen 443 ssl; y http2 on; en líneas separadas.

Cinco. Falta el bloqueo de /install/. El core lo autoborra en producción, pero una regla defensiva no sobra.

Seis. /public/ debe seguir siendo legible. Desde 0.8.0 los assets compartidos viven en /public/assets, /public/gateways y /public/branding. La guía de migración avisa literalmente: “make sure /public remains publicly readable while /data remains blocked”.

Siete. Hay una regla obsoleta de 0.7.x que hay que quitar. Si arrastras location ~* /uploads/.*\.php$ { return 403; }, sustitúyela: los uploads se movieron a /data/uploads y la regla correcta es location ^~ /data/ { return 403; }.

Hay además dos huecos frente al .htaccess: la plantilla de nginx no bloquea /themes/*/config/ —donde vive themes/huraga/config/settings_data.jsonni config.old.php, el respaldo que crea FOSSBilling\Config al actualizar la configuración desde la aplicación.

Bloque nginx endurecido listo para producción

La plantilla oficial con los siete problemas corregidos y los dos huecos tapados, en /etc/nginx/sites-available/fossbilling.conf:

server {
    listen 80;
    server_name billing.example.com;

    location ^~ /.well-known/acme-challenge/ { root /var/www/html; allow all; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    http2 on;

    server_name billing.example.com;
    set $root_path /var/www/fossbilling;
    root $root_path;
    index index.php;

    ssl_certificate         /etc/letsencrypt/live/billing.example.com/fullchain.pem;
    ssl_certificate_key     /etc/letsencrypt/live/billing.example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/billing.example.com/chain.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_stapling on;
    ssl_stapling_verify on;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header X-Frame-Options           "SAMEORIGIN" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;

    client_max_body_size 32m;
    sendfile off;
    include /etc/nginx/mime.types;

    try_files $uri $uri/ @rewrite;

    # Bloqueos. El orden importa: ^~ y = ganan a los regex
    location ^~ /data/    { return 403; }
    location ^~ /vendor/  { return 403; }
    location ^~ /install/ { return 403; }
    location = /config.php     { return 403; }
    location = /config.old.php { return 403; }
    location ~ ^/themes/[^/]+/config/ { return 403; }
    location ~* \.(ini|sh|inc|bak|twig|sql|log|yaml|yml|lock)$ { return 403; }
    location ~ /\.(?!well-known\/) { return 403; }

    location @rewrite {
        rewrite ^/page/(.*)$ /index.php?_url=/custompages/$1;
        rewrite ^/(.*)$      /index.php?_url=/$1;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;   # AJUSTAR a tu version
        fastcgi_param PATH_INFO       $fastcgi_path_info;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_intercept_errors on;
        fastcgi_read_timeout 300;
        include fastcgi_params;
    }

    location ~* ^/(css|img|js|flv|swf|download)/(.+)$ { root $root_path; expires off; }

    location ^~ /public/ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }
}

Las cabeceras de seguridad de este bloque son buenas prácticas generales, no recomendaciones oficiales del proyecto: la documentación de FOSSBilling no publica un conjunto recomendado de cabeceras HTTP. Se profundiza en ellas en el capítulo 16.

sudo ln -s /etc/nginx/sites-available/fossbilling.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Apache y LiteSpeed: el .htaccess incluido, leído línea a línea

Con Apache o LiteSpeed / OpenLiteSpeed no hace falta configuración específica de la aplicación: el ZIP trae un .htaccess de 2.830 bytes que gobierna todo el enrutado. Basta con activar el módulo y permitir los overrides con sudo a2enmod rewrite y un AllowOverride All con Require all granted en el bloque <Directory /var/www/fossbilling>. En OpenLiteSpeed hay que recargar el servicio después de instalar para que recoja el .htaccess nuevo.

Nueve puntos de lectura de ese fichero, en el orden en que aparecen:

Uno. Options -Indexes +SymLinksIfOwnerMatch desactiva el listado de directorios y limita los symlinks a los del mismo dueño.

Dos. Si mod_rewrite no está cargado, el bloque <IfModule !mod_rewrite.c> sirve rewrite-missing.html mediante DirectoryIndex y FallbackResource: una página que explica el problema en lugar de un 404 críptico. Si la ves, el diagnóstico está hecho.

Tres. RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] propaga la cabecera Authorization a PHP. Es necesaria para autenticar con API key bajo CGI y FastCGI, y explica por qué poner Basic Auth propio delante del panel puede interferir con clientes de la API.

Cuatro. Tres RewriteCond sobre QUERY_STRING bloquean patrones clásicos de explotación: base64_encode(...), inyección de <script> y manipulación de GLOBALS o _REQUEST.

Cinco. Cuatro bloques mapean parámetros heredados de BoxBilling a sus nombres actuales —bb_invoice_id a invoice_id, bb_gateway_id a gateway_id, bb_redirect a redirect, bb_invoice_hash a invoice_hash— y RewriteRule ^bb-ipn\.php$ /ipn.php [L] alias el receptor antiguo. El comentario lo justifica: “Required for older PayPal transactions as IPN cannot be updated”, porque la URL de IPN está grabada del lado de PayPal.

Seis. Hay una allowlist explícita: solo index.php, ipn.php, install/index.php e install/install.php se sirven como PHP directo.

Siete. La lista negra por extensión incluye .php. Combinada con la allowlist anterior, ningún otro .php del árbol es accesible por web, lo que es una defensa fuerte contra webshells subidas. Los bloqueos devuelven un 404 renderizado por la aplicación con index.php?_url=/$1&_errcode=404, no un 403 del servidor, así que no filtran si el fichero existe.

Ocho. RewriteCond %{REQUEST_URI} /themes/[^/]+/config/ protege la configuración de los temas. Es la regla que la plantilla de nginx no tiene.

Nueve. La última regla es el front controller estándar: lo que no sea fichero ni directorio existente va a index.php con [QSA,L].

Advertencia para migraciones desde BoxBilling con nginx: la plantilla oficial de nginx no incluye ninguna de las reglas del punto cinco. Si migras y tienes suscripciones antiguas de PayPal, esos IPN no funcionarán sin traducirlas a mano, y reescribir query strings en nginx es bastante más laborioso que en Apache. No existe traducción oficial. Para ese escenario, Apache o LiteSpeed dan menos trabajo. Cómo llegan y se validan esos IPN se ve en el capítulo 10.

TLS con certbot antes de lanzar el instalador y por qué el orden importa

sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d billing.example.com --agree-tos -m [email protected] --redirect
sudo certbot renew --dry-run

La guía oficial pide configurar y verificar el certificado antes de lanzar el asistente: “If you plan to use HTTPS, and you should, configure and verify your certificate before you start the installer.”

El motivo técnico no está en la documentación: el instalador deriva security.force_https del esquema con el que accediste, mediante str_starts_with($systemUrl, 'https://'). Si instalas por HTTP porque «el certificado lo pongo después», te queda force_https => false grabado en config.php y las cookies de sesión sin la marca de seguras. Se arregla a mano, pero es un paso evitable.

El asistente web paso a paso y las validaciones que aplica

Abre https://billing.example.com/ y el instalador arranca solo. Los ocho pasos oficiales: limpiar la raíz de documentos; subir y extraer los ficheros; lanzar el instalador “over https:// so the application configures secure URLs automatically”; completar el asistente con licencia, credenciales de base de datos y cuenta de administrador; confirmar la URL pública que propone; configurar el proxy inverso si lo detecta —“Only enable this if the detected proxy is one you control and trust”—; elegir la moneda por defecto con su formato de precio alrededor del marcador {{price}}, por ejemplo {{price}} USD o $ {{price}}; e instalar. Esa decisión de moneda y sus consecuencias se desarrollan en el capítulo 4.

Las validaciones de validateAdmin() no siempre están explicadas en la interfaz, y son la causa habitual de que el formulario rebote:

ValidaciónMensaje de error exacto
Email válido con FILTER_VALIDATE_EMAILThe admin email is not a valid address.
Longitud mínima de 8 caracteresMinimum admin password length is 8 characters.
Al menos un dígito, regex #[0-9]+#Admin password must include at least one number.
Al menos una minúscula, regex #[a-z]+#Admin password must include at least one lowercase letter.
Al menos una mayúscula, regex #[A-Z]+#Admin password must include at least one uppercase letter.
Nombre no vacíoYou must enter an Admin Name.

Y otras cuatro comprobaciones que conviene conocer. Uno. isAlreadyInstalled() devuelve true con la mera existencia de config.php y rechaza el POST con FOSSBilling is already installed.; el comentario del código es explícito: “Any existing config file must block the public installer.” Dos. El puerto de base de datos se normaliza con FOSSBilling\Tools::normalizePort(), y si es inválido lanza Database port is invalid. Tres. normalizeSystemUrl() añade https:// si falta el esquema, exige que sea http o https y siempre devuelve la URL con barra final. Cuatro. El instalador acepta cuatro formatos de cabecera de proxy —x_forwarded, forwarded, aws_elb y traefik—; cualquier otro lanza The trusted proxy header format is invalid. La documentación de configuración solo menciona los dos primeros: es una discrepancia verificada y útil si tu proxy es un ELB de AWS o Traefik.

Si la instalación falla a mitad, el aviso oficial es directo: “If installation fails, you may need to manually empty the database before retrying.”

Qué hace install por dentro y cómo se borra la carpeta /install

sequenceDiagram
    participant U as Navegador
    participant I as install.php
    participant DB as MariaDB
    participant FS as Ficheros

    U->>I: POST /install/install.php?a=install
    I->>I: isAlreadyInstalled abortar si existe config.php
    I->>DB: PDO connect sin base seleccionada
    I->>DB: CREATE DATABASE en try/catch silencioso
    I->>I: validateAdmin
    I->>FS: leer install/sql/structure.sql y content.sql
    I->>DB: ejecutar cada sentencia del dump
    I->>DB: INSERT admin con hash de PasswordManager
    I->>DB: INSERT admin_group_member con admin_group_id 1
    I->>DB: DELETE currency USD e INSERT moneda por defecto
    I->>FS: copiar settings_data.json.example del tema Huraga
    I->>FS: si falta .htaccess descargarlo de raw.githubusercontent.com
    I->>FS: escribir config.php e invalidar OPcache
    I->>I: UpdateFinalization writeCompleteState
    I->>I: generateEmailTemplates
    I->>FS: borrar install/install.php si debug es false
    I-->>U: pagina de resultado

Cuatro detalles de esa secuencia que importan operativamente:

Uno. El SQL se trocea con preg_split('/\;[\r]*\n/ism', $sql), un split ingenuo por «punto y coma seguido de salto de línea». Funciona con structure.sql y content.sql, y explica por qué no conviene editarlos a mano.

Dos. El administrador creado se mete en el grupo admin_group_id = 1. Desde 0.8.4 los permisos son por grupo, no por usuario; el detalle está en el capítulo 9.

Tres. Si falta el .htaccess, el instalador lo descarga de internet, desde raw.githubusercontent.com/FOSSBilling/FOSSBilling/main/src/.htaccess. En un servidor sin salida a internet y sin ese fichero, la instalación falla con “Unable to write required .htaccess file to … Check file and folder permissions.”, un mensaje engañoso: el problema puede ser de red, no de permisos.

Cuatro. El instalador se autodestruye parcialmente: borra install/install.php, no la carpeta entera, para que la página de resultado conserve sus assets CSS.

La carpeta la borra la aplicación en la primera petición posterior, en src/load.php::checkInstaller():

if ($filesystem->exists(PATH_CONFIG) && $filesystem->exists(Path::normalize('install')) && !DEBUG) {
    $filesystem->remove('install');
}

En producción, si existe config.php y sigue existiendo install/, FOSSBilling la borra sola. Ese borrado depende de que el usuario del servidor web pueda eliminar el directorio. Si no puede, la carpeta se queda y la aplicación no avisa de forma llamativa. El comando manual, que la propia página de éxito te da, es rm -rfv /var/www/fossbilling/install. Esa página añade otras dos tareas: restringir la escritura de config.php640 o 600 es mejor que el 644 que sugiere— y programar */5 * * * * php /var/www/fossbilling/cron.php.

Instalación con Docker: standalone y docker compose oficial

El soporte Docker es oficial y de primera clase: página propia en la documentación, Dockerfile en el repositorio principal, workflow disparado por release: published, imagen fossbilling/fossbilling en Docker Hub con 86.284 pulls y 34 tags, espejo en ghcr.io/fossbilling/fossbilling y arquitecturas linux/amd64 y linux/arm64.

Elemento de la imagenValor
Base del runtimephp:8.5-apache, con ARG PHP_VERSION=8.5
Servidor webApache, no nginx, con a2enmod rewrite: gobierna el .htaccess
Extensiones compiladasdocker-php-ext-install bz2 gd intl pdo_mysql zip, con gd configurado --with-freetype --with-jpeg
Raíz de la aplicación/var/www/html
Comando de arranqueCMD ["sh", "-c", "cron & exec apache2-foreground"]
Cron interno*/5 * * * * /usr/local/bin/php /var/www/html/cron.php >> /var/log/cron.log 2>&1 como www-data

Las demás extensiones requeridas —curl, dom, iconv, json, openssl, xml, zlib, mbstring— vienen de serie en la imagen base.

Gotcha: el contenedor corre dos procesos sin supervisor. El PID 1 es la shell, con cron en segundo plano y Apache en primer plano. Si el proceso cron muere, el contenedor sigue considerándose sano porque Apache sigue vivo, y las facturas dejan de generarse en silencio. Hay que monitorizarlo desde fuera.

La opción standalone exige que tú aportes la base de datos:

docker run -d --name fossbilling -p 80:80 \
  -v fossbilling:/var/www/html --restart unless-stopped \
  fossbilling/fossbilling:latest

Y el docker-compose.yml oficial levanta FOSSBilling y MariaDB juntos:

services:
  fossbilling:
    image: fossbilling/fossbilling:latest
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - fossbilling:/var/www/html
    depends_on:
      - mariadb

  mariadb:
    image: mariadb:lts
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: fossbilling
      MARIADB_USER: fossbilling
      MARIADB_PASSWORD: ${MARIADB_PASSWORD:?set-a-strong-database-password}
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - mariadb:/var/lib/mysql

volumes:
  fossbilling:
  mariadb:

Las credenciales que hay que introducir en el asistente con este compose son hostname mariadb, base fossbilling, usuario fossbilling y el valor de MARIADB_PASSWORD, que se exporta antes de levantar: export MARIADB_PASSWORD="$(openssl rand -base64 32)".

Uno. ${MARIADB_PASSWORD:?set-a-strong-database-password} usa la sintaxis de variable requerida de Compose: si no la defines, docker compose up falla con ese mensaje en vez de arrancar con una contraseña débil. Buen patrón, cópialo. Dos. MARIADB_RANDOM_ROOT_PASSWORD: "1" genera la contraseña de root aleatoria y la imprime una sola vez en los logs; recupérala con docker compose logs mariadb | grep -i "root password". Tres. depends_on sin condition: service_healthy no espera a que MariaDB acepte conexiones, así que en el primer arranque el asistente puede fallar al conectar. Y el compose oficial no publica HTTPS: para producción necesitas un proxy inverso delante.

Compose de producción endurecido: healthcheck, versión fijada y red interna

flowchart LR
    NET["Internet"] --> PROXY["Proxy TLS del host"]
    PROXY --> FB["fossbilling:80 publicado en 127.0.0.1:8080"]
    FB --> VOL["Volumen fossbilling en /var/www/html"]
    FB --> INT["Red interna"]
    INT --> DB["mariadb:3306 sin puerto publicado"]
    DB --> DVOL["Volumen mariadb en /var/lib/mysql"]
    DB -. healthcheck .-> FB
services:
  fossbilling:
    image: fossbilling/fossbilling:0.8.5
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"        # el TLS lo termina el proxy del host
    volumes:
      - fossbilling:/var/www/html
      - ./logs/cron.log:/var/log/cron.log
    depends_on:
      mariadb:
        condition: service_healthy
    networks: [internal]

  mariadb:
    image: mariadb:lts
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: fossbilling
      MARIADB_USER: fossbilling
      MARIADB_PASSWORD: ${MARIADB_PASSWORD:?set-a-strong-database-password}
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    command: >
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_unicode_ci
    volumes:
      - mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 30s
    networks: [internal]

volumes:
  fossbilling:
  mariadb:

networks:
  internal:

Cuatro cambios respecto al oficial y su porqué:

Uno. Versión fijada. La recomendación oficial es usar latest. La política de tags del proyecto es buena —cada release genera un tag con su versión exacta y latest solo se mueve si ese release es realmente el más reciente, comprobado por un job check-latest—, pero fijar 0.8.5 evita que un docker compose pull distraído aplique una actualización sin que hayas leído las notas.

Dos. Base de datos sin puerto publicado y en red interna. Solo el contenedor de aplicación la alcanza.

Tres. command con utf8mb4. Resuelve en el motor lo que el instalador hace mal.

Cuatro. Healthcheck real con condition: service_healthy. El compose de CI del proyecto usa mariadb-admin ping --user=root --password="$MARIADB_ROOT_PASSWORD", pero con MARIADB_RANDOM_ROOT_PASSWORD: "1" esa variable no existe y el chequeo nunca pasa. El healthcheck.sh --connect --innodb_initialized que incluye la imagen oficial de MariaDB sí funciona con root aleatorio; confirma que el script está presente en tu versión de la imagen antes de darlo por bueno.

docker compose up -d
docker compose exec -u www-data fossbilling php /var/www/html/console.php system:version
docker compose exec fossbilling crontab -u www-data -l
docker compose exec fossbilling tail -f /var/log/cron.log

La trampa del volumen: por qué cambiar el tag de la imagen no actualiza nada

Advertencia: este es el punto que más caro sale en Docker y no está en la documentación del proyecto.

El compose oficial monta el árbol entero de la aplicación como volumen, no solo los datos: fossbilling:/var/www/html incluye config.php, data/, themes/, modules/, library/ y vendor/. Cuando Docker monta un volumen con contenido sobre un directorio de la imagen, el volumen tapa lo que trae la imagen.

La consecuencia es directa: cambiar image: fossbilling/fossbilling:0.8.5 por :0.8.6 y ejecutar docker compose up -d no actualiza la aplicación. Arranca un contenedor nuevo con código nuevo dentro de la imagen, monta el volumen encima y sigue ejecutando exactamente el código viejo. La instancia seguirá reportando 0.8.5 en System → About y en console.php system:version aunque docker image ls te muestre otra cosa.

El volumen tiene que existir, porque ahí viven config.php, los uploads y la configuración de los temas. La solución no es quitarlo, es actualizar por el camino correcto: el actualizador integrado, desde System → Update, que sí escribe dentro del volumen. Funciona en dos fases desde 0.8.1 —instalar y finalizar— y la segunda no es opcional: mientras esté pendiente, el cron se detiene por completo.

Dentro del volumen hay que respaldar config.php, data/uploads, data/log, themes/ y modules/. config.php es el que más duele perder: contiene info.salt, y sin el salt original las credenciales cifradas de pasarelas de pago y server managers son irrecuperables. FOSSBilling no trae módulo de backup en el core, así que eso es responsabilidad tuya.

Un matiz adicional para despliegues containerizados: config-sample.php lee la base de datos con getenv('DB_HOST') ?: '127.0.0.1' y equivalentes para DB_NAME, DB_USER, DB_PASS y DB_PORT, pero el config.php que genera el instalador graba los valores literales resueltos, no las llamadas a getenv(). Si quieres seguir configurando la base por variables de entorno, tienes que reponer esos getenv() a mano.

Verificación post-instalación con curl y errores comunes de arranque

Cinco comprobaciones que cierran la instalación, cada una con su código esperado:

test -d /var/www/fossbilling/install && echo "PELIGRO: /install sigue ahi" || echo "OK: /install borrado"
curl -s -o /dev/null -w "%{http_code}\n" https://billing.example.com/install/install.php   # 404 o 403
curl -s -o /dev/null -w "%{http_code}\n" https://billing.example.com/config.php            # 403
curl -s -o /dev/null -w "%{http_code}\n" https://billing.example.com/data/log/             # 403
curl -s -o /dev/null -w "%{http_code}\n" https://billing.example.com/vendor/autoload.php   # 403
curl -s -o /dev/null -w "%{http_code}\n" https://billing.example.com/admin                 # 200

El cron en la ruta VPS lo instalas tú, porque solo la imagen Docker lo trae de fábrica:

sudo touch /var/log/fossbilling-cron.log && sudo chown www-data:www-data /var/log/fossbilling-cron.log
sudo crontab -u www-data -e
# */5 * * * * /usr/bin/php8.3 /var/www/fossbilling/cron.php >> /var/log/fossbilling-cron.log 2>&1

Usa la ruta absoluta al binario, no php a secas: el entorno del cron es mucho más pobre que tu shell y en servidores con varias versiones es habitual que php resuelva a una anterior a 8.3.

Cuando algo no arranca, el árbol de decisión empieza por lo que se ve en el navegador:

flowchart TD
    A["El sitio no funciona"] --> B{"Que se ve?"}
    B -->|rewrite-missing.html| C["Falta mod_rewrite o AllowOverride"]
    C --> C1["a2enmod rewrite y AllowOverride All"]
    B -->|404 en partes del sitio| D{"Solo funciona el area de cliente?"}
    D -->|Si| E["Instalacion en subcarpeta: no soportada"]
    E --> E1["Mover a un subdominio dedicado"]
    D -->|No| F["Revisar try_files y el bloque @rewrite de nginx"]
    B -->|Pantalla en blanco| G{"Existe el directorio vendor?"}
    G -->|No| H["ZIP incompleto: excepcion codigo 1 composer packages are missing"]
    G -->|Si| I["Leer php_error.log y exception_handler.log"]
    B -->|HTTP 500| J["php -l config.php y revisar extensiones y permisos"]
    B -->|Error encore_entry_link_tags| K["Tema de 0.7.x sin migrar o subcarpeta"]

La pantalla en blanco es comportamiento por diseño, no un fallo. src/install/install.php fija ini_set('display_errors', 0) y ini_set('log_errors', '1'), y load.php fija ini_set('error_log', Path::join(PATH_LOG, 'php_error.log')). Los errores nunca se muestran en pantalla, siempre se registran:

sudo tail -50 /var/www/fossbilling/data/log/php_error.log
sudo tail -50 /var/www/fossbilling/data/log/exception_handler.log
sudo tail -50 /var/log/nginx/fossbilling.error.log

Gotcha durante la instalación: el error_log del instalador es la ruta relativa 'php_error.log', así que el fichero acaba en el directorio de trabajo del proceso —típicamente /var/www/fossbilling/install/ o la raíz—, no en data/log/. Si solo buscas ahí, no lo encuentras; usa sudo find /var/www/fossbilling -name php_error.log -newermt '-1 hour' -ls.

Si los logs están vacíos, el fallo es anterior a que PHP pueda registrar nada. Lo primero es vendor/: load.php lanza literalmente throw new Exception('The composer packages are missing.', 1) si no existe PATH_VENDOR. Los códigos de excepción de arranque son 1 para vendor/ ausente, 2 para install/ presente en producción, 3 para config.php inválido y 5 para .htaccess ausente.

Errores comunes y diagnóstico

SíntomaCausa más probableDiagnósticoSolución
ZIP de 9 bytes, unzip falla/downloads/stable devuelve 404file FOSSBilling.zipDescargar FOSSBilling-0.8.5.zip o resolverlo por la API de GitHub
Botón de instalar deshabilitadoFalta una extensión requerida, casi siempre gdLa primera pantalla lista lo que faltaapt install php8.3-gd y reiniciar FPM
Error 101 Configuration file is not writablePHP no puede crear config.phpsudo -u www-data test -w /var/www/fossbillingchown www-data:www-data en la raíz, endurecer a 640 después
Unable to write required .htaccess fileFalta el .htaccess y no hay salida a internetls -l .htaccess y probar curl a GitHubCopiar el .htaccess a mano desde el ZIP
FOSSBilling is already installedExiste config.php de un intento previols -l config.phpBorrar o renombrar config.php y vaciar la base
Instalación falla a mitadBase con datos parcialesVaciar la base manualmente y reintentar
Base en utf8_general_ciLa creó el instalador, no túSHOW CREATE DATABASE fossbilling;Recrearla en utf8mb4_unicode_ci antes de instalar
Pantalla en blancoError fatal con display_errors=0tail data/log/php_error.logCorregir según el log; revisar extensiones y memory_limit
HTTP 500Permisos, extensión faltante o sintaxis de config.phpphp -l config.php y log de nginxReparar según la capa que falle
404 en ApacheFalta mod_rewrite o AllowOverride¿Se sirve rewrite-missing.html?a2enmod rewrite y AllowOverride All
404 en nginxFalta el named location @rewritenginx -T | grep -A3 '@rewrite'Cargar el bloque endurecido de este capítulo
Solo carga el área de clienteInstalación en subcarpetaMirar la URLMover a subdominio dedicado; no hay workaround
Unknown "encore_entry_link_tags" functionTema de 0.7.x sin migrar a esbuild, o subcarpetaRevisar el tema activoMigrar el tema o volver a uno incluido
502 Bad GatewayEl socket de FPM no está donde dice nginxls -l /run/php/Corregir el placeholder phpx.x-fpm.sock
/install sigue existiendoEl servidor web no pudo borrar el directoriotest -d installrm -rfv /var/www/fossbilling/install
Bucle de redirección infinitoforce_https detrás de proxy sin trusted_proxiescurl -I y contar saltosConfigurar trusted_proxies y reenviar X-Forwarded-Proto
Aviso de cron en el dashboardCron ausente o con PHP anterior a 8.3sudo crontab -u www-data -lRuta absoluta /usr/bin/php8.3
Caché con propietario rootEjecutaste console.php o el cron como rootls -l data/cachechown -R www-data:www-data data
Panel sin estilos, error sha384Cloudflare Auto Minify rompe los hashes SRIConsola del navegadorDesactivar Auto Minify
Errores con curl_multi_execLa función está en disable_functionsphp -i | grep disable_functionsQuitarla de php.ini y reiniciar FPM
Docker: subiste el tag y sigue la versión viejaEl volumen tapa el código de la imagenconsole.php system:versionActualizar desde System → Update
Docker: el cron dejó de correrEl proceso cron murió sin supervisordocker compose exec fossbilling pgrep crondocker compose restart fossbilling

Lo que queda funcionando

Tienes FOSSBilling 0.8.5 sirviendo por HTTPS en su propio dominio, con la base en utf8mb4, las extensiones que el código exige de verdad —incluida gd—, /install borrado, /data, /vendor, config.php, config.old.php y la configuración de los temas inaccesibles por web, y el cron latiendo cada cinco minutos. En la ruta Docker tienes lo mismo con la versión fijada, la base en red interna sin puerto publicado, healthcheck real y el conocimiento de que las actualizaciones van por el actualizador integrado y no por el tag de la imagen.

Lo que todavía no has tocado es el único fichero que gobierna todo esto. config.php decide si las cookies son estrictas, si confías en un proxy, dónde viven los datos, qué políticas de rate limiting aplican a cada endpoint y qué se registra en cada canal de log. Y su documentación oficial tiene seis discrepancias verificadas con el código, incluidas dos claves que están documentadas y no existen.

Eso es exactamente el capítulo 3: config.php de la A a la Z, los seis comandos reales de console.php y los once canales de Monolog con su rotación de 90 días.