← Back to Blog← Zurück zum Blog

CI was green. My live site stayed stale.

I was wiring GitHub Actions so this portfolio would deploy to Hostinger over FTPS on every push to main. Build the Vite site, upload the files, done. That was the plan.

The workflow used SamKirkland/FTP-Deploy-Action. Secrets for host, username and password were set. The job finished with a green check. I refreshed shoaibsarwar.com and saw the old version.

I did the usual useless debugging first. Hard refresh. Different browser. Wait a few minutes for “propagation”. None of that helped, because the new files were never where the domain looks.

That combination is annoying. CI says success. The browser says nothing changed.

What went wrong

In .github/workflows/git-cli.yml I had defaulted the remote folder like this:

server-dir: ${{ secrets.HOSTINGER_REMOTE_DIR || 'public_html/' }}

On Hostinger, an FTP account often already opens inside the website directory (commonly labelled public_html in hPanel). From that login, public_html/ is not “the web root”. It is a subdirectory named public_html.

So the action uploaded into something like public_html/public_html/. The real site root — the folder the domain actually serves — never got the new build. The job still reported success because the upload itself worked. It just landed in the wrong place.

I confirmed it in Hostinger’s File Manager: the familiar site files at the real web root, and a nested public_html folder next to them holding the “successful” deploy. Embarrassing, and very easy to miss if you only stare at the Actions tab. The image below is an illustration of that layout, not a screenshot from my account.

Illustrative file manager view: nested public_html folder under the real web root
Illustration recreating the nested folder — not a real Hostinger screenshot.
FTP login already in public_html, then server-dir public_html nests another folder FTP login cwd …/public_html server-dir public_html/ files land in public_html/public_html domain still serves the outer public_html (stale)

Relative server-dir is joined to the FTP account’s starting directory — not to “the domain”.

Why it really happened

I treated public_html as a universal name for “where websites live”. On shared hosting that label shows up everywhere, so it felt safe as a default. It was not.

FTP paths are relative to the account’s home directory. Hostinger’s own docs describe setting an FTP account’s Directory to public_html so that login lands in the web folder. The FTP-Deploy-Action docs say server-dir defaults to ./ — the FTP account root — and that a value like public_html/ is an extra folder under that root.

My default stacked those two meanings. Green CI only proved that bytes were written somewhere the FTP user could write. It did not prove that the public URL was serving this build.

How I fixed it

Commit 6ad5614 changed the default remote directory from public_html/ to ./, and 3559229 documented the nesting trap in the workflow comments:

# IMPORTANT: Do NOT set HOSTINGER_REMOTE_DIR to "public_html" when FTP already
# opens inside public_html — that creates public_html/public_html/ (wrong).
# Leave HOSTINGER_REMOTE_DIR unset so server-dir is "./" (site root).
server-dir: ${{ secrets.HOSTINGER_REMOTE_DIR || './' }}

Later I stopped trusting “upload finished” alone. The same workflow now detects whether index.html sits at the FTP login root or under public_html, mirrors into that folder with lftp, and fails the job unless the live site HTML matches the build that just ran (see commits like 5e7f19a and aa7a999). If the public page still serves an old hashed main-….js name, the pipeline is red. That is the check I should have had on day one.

Illustrative CI job log showing the live-site verification step after FTPS deploy
Simplified view of the verification step — not a real GitHub Actions screenshot.

How I catch it now

A short checklist I keep next to any FTP deploy:

Takeaway

Deploy success is not “FTP accepted the files”. Deploy success is “the URL visitors hit serves this build”. Path defaults copy-pasted from hosting tutorials are not neutral. They assume a specific FTP home directory. Mine did not match that assumption.

I learned that on my own portfolio, in a one-line YAML default that looked completely normal. Tutorials rarely spend a chapter on “your CI lied because the folder was nested”. Production did.

Further reading

CI war grün. Die Live-Site blieb alt.

Ich habe GitHub Actions so eingerichtet, dass dieses Portfolio bei jedem Push auf main per FTPS zu Hostinger deployed. Vite-Build, Dateien hochladen, fertig. So der Plan.

Der Workflow nutzte SamKirkland/FTP-Deploy-Action. Secrets für Host, Benutzer und Passwort waren gesetzt. Der Job endete mit einem grünen Haken. Ich habe shoaibsarwar.com neu geladen und die alte Version gesehen.

Zuerst habe ich sinnlos debuggt. Hard Refresh. Anderer Browser. Ein paar Minuten auf „Propagation“ gewartet. Nichts davon half, weil die neuen Dateien nie dort lagen, wo die Domain hinsieht.

Diese Kombination nervt. CI sagt Erfolg. Der Browser sagt: nichts geändert.

Was schiefging

In .github/workflows/git-cli.yml hatte ich den Remote-Ordner so als Default gesetzt:

server-dir: ${{ secrets.HOSTINGER_REMOTE_DIR || 'public_html/' }}

Bei Hostinger öffnet ein FTP-Konto oft schon innerhalb des Website-Verzeichnisses (in hPanel häufig public_html). Von diesem Login aus ist public_html/ nicht „das Web-Root“, sondern ein Unterordner mit dem Namen public_html.

Die Action hat also nach public_html/public_html/ hochgeladen. Das echte Site-Root — der Ordner, den die Domain wirklich ausliefert — bekam den neuen Build nicht. Der Job war trotzdem grün, weil der Upload technisch geklappt hat. Nur am falschen Ort.

Im Hostinger-Dateimanager war es klar: die gewohnten Site-Dateien im echten Web-Root, und daneben ein verschachtelter Ordner public_html mit dem „erfolgreichen“ Deploy. Peinlich — und leicht zu übersehen, wenn man nur den Actions-Tab anstarrt. Das Bild darunter ist eine Illustration dieser Struktur, kein Screenshot aus meinem Konto.

Illustrierte Dateimanager-Ansicht: verschachtelter public_html-Ordner unter dem echten Web-Root
Illustration des verschachtelten Ordners — kein echter Hostinger-Screenshot.
FTP-Login schon in public_html, server-dir public_html erzeugt einen weiteren Ordner FTP-Login cwd …/public_html server-dir public_html/ Dateien landen in public_html/public_html Domain liefert weiter äußeres public_html (veraltet)

Relatives server-dir hängt am Startverzeichnis des FTP-Kontos — nicht an „der Domain“.

Warum das wirklich passiert ist

Ich habe public_html als universellen Namen für „dort liegen Websites“ behandelt. Auf Shared Hosting steht dieses Label überall, deshalb wirkte der Default harmlos. War er nicht.

FTP-Pfade sind relativ zum Home-Verzeichnis des Kontos. Hostinger beschreibt in der Doku, dass man das Directory eines FTP-Kontos auf public_html setzen kann — dann landet der Login schon im Web-Ordner. Die FTP-Deploy-Action sagt: server-dir ist standardmäßig ./ (FTP-Root), und ein Wert wie public_html/ ist ein zusätzlicher Ordner darunter.

Mein Default hat beide Bedeutungen gestapelt. Grünes CI bewies nur: irgendwo, wohin der FTP-User schreiben darf, liegen neue Bytes. Es bewies nicht, dass die öffentliche URL diesen Build ausliefert.

So habe ich es behoben

Commit 6ad5614 hat den Default von public_html/ auf ./ geändert. 3559229 dokumentiert die Verschachtelungsfalle in den Workflow-Kommentaren:

# IMPORTANT: Do NOT set HOSTINGER_REMOTE_DIR to "public_html" when FTP already
# opens inside public_html — that creates public_html/public_html/ (wrong).
# Leave HOSTINGER_REMOTE_DIR unset so server-dir is "./" (site root).
server-dir: ${{ secrets.HOSTINGER_REMOTE_DIR || './' }}

Später habe ich aufgehört, nur dem Upload zu vertrauen. Derselbe Workflow erkennt jetzt, ob index.html am FTP-Login-Root oder unter public_html liegt, spiegelt dorthin mit lftp und lässt den Job fehlschlagen, wenn die Live-HTML nicht zum gerade gebauten Stand passt (siehe u. a. 5e7f19a und aa7a999). Liefert die öffentliche Seite noch einen alten gehashten main-….js-Namen, wird die Pipeline rot. Genau diesen Check hätte ich am ersten Tag gebraucht.

Illustriertes CI-Job-Log mit dem Schritt zur Prüfung der Live-Site nach dem FTPS-Deploy
Vereinfachte Darstellung des Verify-Schritts — kein echter GitHub-Actions-Screenshot.

So fange ich es heute ab

Kurze Checkliste neben jedem FTP-Deploy:

Fazit

Deploy-Erfolg heißt nicht „FTP hat die Dateien angenommen“. Deploy-Erfolg heißt: „Die URL, die Besucher öffnen, liefert diesen Build.“ Pfad-Defaults aus Hosting-Tutorials sind nicht neutral. Sie setzen ein bestimmtes FTP-Home voraus. Meines passte nicht dazu.

Das habe ich an meinem eigenen Portfolio gelernt — an einer YAML-Zeile, die völlig normal aussah. Tutorials widmen selten ein Kapitel dem Satz „dein CI hat gelogen, weil der Ordner verschachtelt war“. Die Produktion schon.

Weiterlesen