← Back to writing

Cómo conecté el sitio desde GitHub a Hostinger

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 hPanel

2 · 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, no require-dev: composer install --no-dev en 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 genera web/autoload.php con ruta ./vendor/autoload.php, sin ../. Necesario porque el docroot de Hostinger es public_html — un vendor afuera no resuelve.
  • bin-dir = web/vendor/bin: matching del anterior, mantiene vendor/bin/drush reachable 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.php

El ú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.php

El 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>' \
  -y

De 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.php

Y 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.