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 hPanel2 · 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, notrequire-dev:composer install --no-devin 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 generatesweb/autoload.phppointing at./vendor/autoload.php, no../. Required because Hostinger's docroot ispublic_html— a vendor outside it doesn't resolve.bin-dir = web/vendor/bin: matches the previous, keepsvendor/bin/drushreachable 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.phpThe 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.phpThe 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>' \
-yFrom 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.phpAnd 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.