El sitio que estás leyendo se construye en GitHub Actions y aterriza en Hostinger Cloud Startup vía SSH + rsync. Sin Composer ni Node corriendo en el servidor, sin paneles de control. Cada git push a main dispara un build en CI, compila los assets, y publica solo el artefacto final en producción.
Esta nota resume el setup en 7 pasos. Asume Drupal 11 + Drupal Canvas, PHP 8.4, MySQL local en Hostinger, y un repo en GitHub.
1 · Hostinger en hPanel
Antes de tocar código, en hPanel → Hosting → Manage:
- Avanzado → Selector de PHP: PHP 8.4.
- Bases de datos → MySQL: crear DB y usuario, anotar credenciales.
- Avanzado → Acceso SSH: habilitar SSH, anotar host (IP), puerto (siempre 65002 en Hostinger Cloud) y user (
u<id>). - Acceso SSH → Manage SSH Keys: pegar la llave pública ed25519.
Generar la llave SSH localmente (sin passphrase: CI no puede teclear contraseñas):
ssh-keygen -t ed25519 -f ~/.ssh/hostinger_jalvarez -C "deploy@github-actions"
chmod 600 ~/.ssh/hostinger_jalvarez
cat ~/.ssh/hostinger_jalvarez.pub | pbcopy # pegar en hPanel2 · Secretos en GitHub
En el repo: Settings → Secrets and variables → Actions (no Deploy keys). Diez valores:
HOSTINGER_HOST,HOSTINGER_PORT(65002),HOSTINGER_USER(u<id>),HOSTINGER_PATH(/home/u<id>/domains/<site>).HOSTINGER_SSH_KEY: el contenido completo de la llave privada (con newline final).DRUPAL_DB_NAME,DRUPAL_DB_USER,DRUPAL_DB_PASS,DRUPAL_DB_HOST(localhost),DRUPAL_HASH_SALT(openssl rand -base64 55 | tr -d '\n').
3 · Composer ajustado para shared hosting
Cuatro cambios en composer.json que ahorran horas de debugging:
{
"require": {
"drush/drush": "^13.7"
},
"config": {
"platform": { "php": "8.4.0" },
"vendor-dir": "web/vendor",
"bin-dir": "web/vendor/bin"
}
}- drush en
require, norequire-dev:composer install --no-deven CI sigue conservándolo para los hooks post-deploy. platform.php: fuerza a Composer a resolver paquetes contra la versión PHP del servidor, no la del dev local.vendor-dir = web/vendor: drupal-scaffold generaweb/autoload.phpcon ruta./vendor/autoload.php, sin../. Necesario porque el docroot de Hostinger espublic_html— un vendor afuera no resuelve.bin-dir = web/vendor/bin: matching del anterior, mantienevendor/bin/drushreachable desde el docroot.
4 · settings.php desde plantilla con envsubst
El truco: envsubst sin variable list explícita expande cada token $VAR, incluyendo las variables PHP del template ($databases, $settings, $config). Resultado: settings.php queda corrupto, drush boot falla con errores misteriosos.
La forma correcta — pasar la lista explícita:
- name: Render settings.php
env:
DRUPAL_DB_NAME: ${{ secrets.DRUPAL_DB_NAME }}
DRUPAL_DB_USER: ${{ secrets.DRUPAL_DB_USER }}
DRUPAL_DB_PASS: ${{ secrets.DRUPAL_DB_PASS }}
DRUPAL_DB_HOST: ${{ secrets.DRUPAL_DB_HOST }}
DRUPAL_HASH_SALT: ${{ secrets.DRUPAL_HASH_SALT }}
run: |
envsubst '${DRUPAL_DB_NAME} ${DRUPAL_DB_USER} ${DRUPAL_DB_PASS} ${DRUPAL_DB_HOST} ${DRUPAL_HASH_SALT}' \
< web/sites/default/settings.hostinger.php.template \
> web/sites/default/settings.php
grep -q '^\$databases' web/sites/default/settings.php || (echo "::error::envsubst stripped \$databases" && exit 1)
php -l web/sites/default/settings.phpEl último grep es el guardarraíl: si vuelve a fallar la sustitución, el deploy aborta antes de pisar producción.
5 · Layout en Hostinger (flat + dos symlinks)
Cómo queda el filesystem en el servidor:
/home/u<id>/domains/<site>/
├── composer.json ← deployed (RecipeHandler lo lee)
├── composer.lock ← deployed
├── recipes/ ← deployed (el installer escanea aquí)
├── web → public_html ← SYMLINK (composer autoloader lo recorre)
└── public_html/ ← DOCROOT
├── core/
├── modules/
├── themes/
├── vendor/ ← composer install (vendor-dir = web/vendor)
├── config/sync/
├── private/
├── sites/default/
│ ├── settings.php ← rendered by CI envsubst
│ └── files/
└── index.phpEl symlink web → public_html vive UNA carpeta arriba del docroot, así que no es accesible por web. Existe solo para que el autoloader (que bake-eo paths como web/core/... al instalar) los pueda resolver en runtime.
6 · Workflow de deploy
Una sola job en GitHub Actions ejecuta build + ship + post-deploy:
name: Deploy to Hostinger
on:
push: { branches: [main] }
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', tools: 'composer:v2' }
- run: composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm', cache-dependency-path: web/themes/custom/byte/package-lock.json }
- working-directory: web/themes/custom/byte
run: npm ci && npm run build
# Render settings.php (paso 4)
- name: Render settings.php
run: envsubst '...' < template > web/sites/default/settings.php
- uses: shimataro/ssh-key-action@v2
with: { key: ${{ secrets.HOSTINGER_SSH_KEY }}, name: hostinger, ... }
- run: rsync -avz --delete --exclude-from=.deployignore -e ssh ./web/ hostinger:${{ secrets.HOSTINGER_PATH }}/public_html/
- run: rsync -avz --delete -e ssh ./config/ hostinger:${{ secrets.HOSTINGER_PATH }}/public_html/config/
- run: rsync -avz -e ssh ./composer.json ./composer.lock hostinger:${{ secrets.HOSTINGER_PATH }}/
- name: Drupal post-deploy
run: |
ssh hostinger "
cd ${{ secrets.HOSTINGER_PATH }}/public_html
./vendor/bin/drush updb -y
./vendor/bin/drush cim -y || true
./vendor/bin/drush cr
"El --exclude-from=.deployignore deja fuera fuentes SCSS, package.json, node_modules y todo lo que CI ya compiló — solo viaja el artefacto final.
7 · Primera instalación (una sola vez)
Tras el primer push, el código está en el servidor pero no hay tablas. Vía SSH:
ssh -i ~/.ssh/hostinger_jalvarez -p 65002 u<id>@<host>
cd domains/<site>/public_html
# Apuntar al PHP CLI correcto (Hostinger usa /usr/bin/php por defecto, viejo)
echo 'export PATH=/opt/alt/php84/usr/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
./vendor/bin/drush site:install minimal \
--site-name='jalvarez.tech' \
--account-name=admin \
--account-mail=contacto@jalvarez.tech \
--account-pass='<strong-password>' \
-yDe ahí en adelante cada git push origin main hace el ciclo completo solo: build, transfer, updb + cim + cr. Si Drupal ya está bootstrap-eable, el workflow sigue corriendo. Si todavía no, se salta los hooks y avisa.
Bonus — endurecer permisos al final
Hostinger se queja si settings.php queda 644 después del deploy. El último step revierte permisos al estado seguro:
chmod 555 sites/default
chmod 444 sites/default/settings.phpY el chmod u+w al inicio del próximo deploy se encarga de re-abrirlos cuando rsync necesite escribir.
El stack completo está documentado en el skill drupal-hostinger-deploy: 28 traps específicos que muerden la primera vez (envsubst, symlink, autoloader path, language prefix de Canvas, translation wipes…) y patterns reutilizables para footer bilingüe, cards con cover image, y scripts de mantenimiento. Cada trap es una hora de debug ahorrada.