Instalación: VPS con nginx, PHP-FPM y MariaDB, y con Docker
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.
| Requisito | Documentación oficial | Código de 0.8.5 |
|---|---|---|
| PHP | 8.3, 8.4 y 8.5 soportadas | min_version = '8.3' |
| Extensiones requeridas | curl, dom, iconv, intl, json, openssl, pdo_mysql, xml, zlib | las mismas más gd |
| Sugeridas | mbstring, opcache, imagick o gd, simplexml | mbstring, opcache, imagick, bz2, simplexml, xml |
| Base de datos | MySQL >= 8.4, MariaDB >= 11.4, utf8mb4 / utf8mb4_unicode_ci | el instalador la crea en utf8 / utf8_general_ci |
| Sistema operativo | no se menciona | os_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ón | PHP en repos base | ¿Sirve para 0.8.5? | Acción |
|---|---|---|---|
| Debian 13 «trixie» | php8.4 | Sí, directo | apt install php8.4-fpm ... |
| Debian 12 «bookworm» | php8.2 | No | Repositorio Sury |
| Debian 11 «bullseye» | php7.4 | No | Sury |
| Ubuntu 24.04 «noble» | php8.3 | Sí, directo | apt install php8.3-fpm ... |
| Ubuntu 22.04 «jammy» | php8.1 | No | PPA 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 bloque | Funció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 @rewrite | rewrite ^/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.json— ni 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ón | Mensaje de error exacto |
|---|---|
Email válido con FILTER_VALIDATE_EMAIL | The admin email is not a valid address. |
| Longitud mínima de 8 caracteres | Minimum 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ío | You 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.php —640 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 imagen | Valor |
|---|---|
| Base del runtime | php:8.5-apache, con ARG PHP_VERSION=8.5 |
| Servidor web | Apache, no nginx, con a2enmod rewrite: gobierna el .htaccess |
| Extensiones compiladas | docker-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 arranque | CMD ["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íntoma | Causa más probable | Diagnóstico | Solución |
|---|---|---|---|
ZIP de 9 bytes, unzip falla | /downloads/stable devuelve 404 | file FOSSBilling.zip | Descargar FOSSBilling-0.8.5.zip o resolverlo por la API de GitHub |
| Botón de instalar deshabilitado | Falta una extensión requerida, casi siempre gd | La primera pantalla lista lo que falta | apt install php8.3-gd y reiniciar FPM |
Error 101 Configuration file is not writable | PHP no puede crear config.php | sudo -u www-data test -w /var/www/fossbilling | chown www-data:www-data en la raíz, endurecer a 640 después |
Unable to write required .htaccess file | Falta el .htaccess y no hay salida a internet | ls -l .htaccess y probar curl a GitHub | Copiar el .htaccess a mano desde el ZIP |
FOSSBilling is already installed | Existe config.php de un intento previo | ls -l config.php | Borrar o renombrar config.php y vaciar la base |
| Instalación falla a mitad | Base con datos parciales | — | Vaciar la base manualmente y reintentar |
Base en utf8_general_ci | La creó el instalador, no tú | SHOW CREATE DATABASE fossbilling; | Recrearla en utf8mb4_unicode_ci antes de instalar |
| Pantalla en blanco | Error fatal con display_errors=0 | tail data/log/php_error.log | Corregir según el log; revisar extensiones y memory_limit |
| HTTP 500 | Permisos, extensión faltante o sintaxis de config.php | php -l config.php y log de nginx | Reparar según la capa que falle |
| 404 en Apache | Falta mod_rewrite o AllowOverride | ¿Se sirve rewrite-missing.html? | a2enmod rewrite y AllowOverride All |
| 404 en nginx | Falta el named location @rewrite | nginx -T | grep -A3 '@rewrite' | Cargar el bloque endurecido de este capítulo |
| Solo carga el área de cliente | Instalación en subcarpeta | Mirar la URL | Mover a subdominio dedicado; no hay workaround |
Unknown "encore_entry_link_tags" function | Tema de 0.7.x sin migrar a esbuild, o subcarpeta | Revisar el tema activo | Migrar el tema o volver a uno incluido |
| 502 Bad Gateway | El socket de FPM no está donde dice nginx | ls -l /run/php/ | Corregir el placeholder phpx.x-fpm.sock |
/install sigue existiendo | El servidor web no pudo borrar el directorio | test -d install | rm -rfv /var/www/fossbilling/install |
| Bucle de redirección infinito | force_https detrás de proxy sin trusted_proxies | curl -I y contar saltos | Configurar trusted_proxies y reenviar X-Forwarded-Proto |
| Aviso de cron en el dashboard | Cron ausente o con PHP anterior a 8.3 | sudo crontab -u www-data -l | Ruta absoluta /usr/bin/php8.3 |
Caché con propietario root | Ejecutaste console.php o el cron como root | ls -l data/cache | chown -R www-data:www-data data |
Panel sin estilos, error sha384 | Cloudflare Auto Minify rompe los hashes SRI | Consola del navegador | Desactivar Auto Minify |
Errores con curl_multi_exec | La función está en disable_functions | php -i | grep disable_functions | Quitarla de php.ini y reiniciar FPM |
| Docker: subiste el tag y sigue la versión vieja | El volumen tapa el código de la imagen | console.php system:version | Actualizar desde System → Update |
| Docker: el cron dejó de correr | El proceso cron murió sin supervisor | docker compose exec fossbilling pgrep cron | docker 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.