← Back to writing

How I wired this site from GitHub to Hostinger

The site you're reading builds in GitHub Actions and lands on Hostinger Cloud Startup over SSH + rsync. No Composer or Node running on the server, no control panels. Every git push to main kicks off a CI build, compiles the assets, and ships only the final artefact.

This note walks the setup in 7 steps. Assumes Drupal 11 + Drupal Canvas, PHP 8.4, MySQL on Hostinger, and a GitHub repo.

1 · Hostinger via hPanel

Before touching code, in hPanel → Hosting → Manage:

  • Advanced → PHP Selector: PHP 8.4.
  • Databases → MySQL: create DB + user, note the credentials.
  • Advanced → SSH Access: enable SSH, note host (IP), port (always 65002 on Hostinger Cloud), user (u<id>).
  • SSH Access → Manage SSH Keys: paste the ed25519 public key.

Generate the key locally (no passphrase: CI can't type passwords):

ssh-keygen -t ed25519 -f ~/.ssh/hostinger_jalvarez -C "deploy@github-actions"
chmod 600 ~/.ssh/hostinger_jalvarez
cat ~/.ssh/hostinger_jalvarez.pub | pbcopy   # paste in hPanel

2 · GitHub secrets

In the repo: Settings → Secrets and variables → Actions (not Deploy keys). Ten values:

  • HOSTINGER_HOST, HOSTINGER_PORT (65002), HOSTINGER_USER (u<id>), HOSTINGER_PATH (/home/u<id>/domains/<site>).
  • HOSTINGER_SSH_KEY: full contents of the private key (with trailing newline).
  • 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 adjusted for shared hosting

Four edits to composer.json that save hours of debugging:

{
  "require": {
    "drush/drush": "^13.7"
  },
  "config": {
    "platform": { "php": "8.4.0" },
    "vendor-dir": "web/vendor",
    "bin-dir": "web/vendor/bin"
  }
}
  • drush in require, not require-dev: composer install --no-dev in CI keeps it for post-deploy hooks.
  • platform.php: forces Composer to resolve packages against the server's PHP, not the dev's local PHP.
  • vendor-dir = web/vendor: drupal-scaffold generates web/autoload.php pointing at ./vendor/autoload.php, no ../. Required because Hostinger's docroot is public_html — a vendor outside it doesn't resolve.
  • bin-dir = web/vendor/bin: matches the previous, keeps vendor/bin/drush reachable from the docroot.

4 · settings.php from a template via envsubst

The trap: envsubst with no explicit variable list expands every $VAR-looking token, including PHP variables in the template ($databases, $settings, $config). Result: settings.php ends corrupted, drush bootstrap fails with cryptic errors.

The right way — pass the explicit list:

- 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

The final grep is the guardrail: if substitution ever breaks again, the deploy aborts before touching production.

5 · Layout on Hostinger (flat + two symlinks)

How the filesystem ends up on the server:

/home/u<id>/domains/<site>/
├── composer.json              ← deployed (RecipeHandler reads it)
├── composer.lock              ← deployed
├── recipes/                   ← deployed (installer scans here)
├── web → public_html          ← SYMLINK (composer autoloader walks it)
└── 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

The web → public_html symlink lives ONE level above the docroot, so it isn't web-accessible. It exists only so the autoloader (which bakes paths like web/core/... at install time) can resolve them at runtime.

6 · Deploy workflow

One GitHub Actions job runs 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 (step 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
          "

The --exclude-from=.deployignore filters out SCSS sources, package.json, node_modules, anything CI already compiled — only the final artefact ships.

7 · First install (once)

After the first push, the code is on the server but no tables exist yet. Via SSH:

ssh -i ~/.ssh/hostinger_jalvarez -p 65002 u<id>@<host>
cd domains/<site>/public_html

# Point at the right PHP CLI (Hostinger defaults to old /usr/bin/php)
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

From there on every git push origin main runs the full cycle on its own: build, transfer, updb + cim + cr. If Drupal can bootstrap, the workflow keeps going. If it can't yet, it skips the hooks and warns.

Bonus — re-harden permissions at the end

Hostinger complains if settings.php stays 644 after deploy. The last step reverts to a safe state:

chmod 555 sites/default
chmod 444 sites/default/settings.php

And the chmod u+w at the start of the next deploy reopens them when rsync needs to write.

The full stack is documented in the drupal-hostinger-deploy skill: 28 specific traps that bite first-time setups (envsubst, symlink, autoloader path, Canvas's URL language prefix, translation wipes…) and reusable patterns for bilingual footer, cards with cover image, and maintenance scripts. Each trap is an hour of debugging saved.