Deploying a Jekyll Site to Shared Hosting with GitHub Actions
By Ronald Garcia · · jekyll, devops
I wanted a personal site that would outlive whatever trendy host I picked this year.
I already had Namecheap shared hosting sitting there, paid for, mostly empty. Every Jekyll tutorial I found assumed I’d deploy to GitHub Pages or Netlify. Both are fine — but I didn’t want my site’s uptime tied to a platform I might want to leave in two years. Static HTML files on a boring FTP host will still work in a decade. The trick was getting a modern push-to-deploy workflow on top of that boring host.
Here’s what I built.
Why shared hosting + GitHub Actions
Shared hosting gets a bad rap because everyone remembers dragging files around in FileZilla. That part is genuinely miserable. But the hosting itself — a Linux box that serves static files over HTTPS for a few dollars a month — is completely fine for a personal site.
The problem was never the host. It was the deploy step.
GitHub Actions fixes the deploy step. I write a post, commit, push. A workflow spins up, builds the site, and FTPs the output to /public_html/. Two minutes later it’s live. No FileZilla, no “wait, did I upload the right folder?”, no vendor lock-in. If Namecheap ever annoys me I move the DNS and the site, and the workflow keeps working with a new FTP host.
The pipeline in one picture
Push to GitHub (main branch)
↓
GitHub Actions builds the site (~2 minutes)
↓
Files uploaded to Namecheap → public_html/
↓
ronaldpgarcia.com is live
That’s the entire mental model. Everything else is filling in the details.
The workflow file
Here’s the full .github/workflows/deploy.yml:
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install Node dependencies
run: npm ci
- name: Copy USWDS assets
run: npx gulp copyAll
- name: Restore custom Sass theme files
run: git checkout -- _sass/
- name: Compile USWDS CSS
run: npx gulp compile
- name: Build Jekyll site
run: bundle exec jekyll build
env:
JEKYLL_ENV: production
- name: Deploy to Namecheap via FTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: $
username: $
password: $
local-dir: ./_site/
server-dir: /public_html/
dangerous-clean-slate: false
exclude: |
**/assets/fonts/**
**/assets/img/**
**/assets/js/**
**/assets/uswds/**
A few things are worth walking through, because they’re the parts that took me the longest to figure out.
The two-stage build. This site uses the U.S. Web Design System, and USWDS isn’t a Jekyll plugin — it’s compiled separately by Gulp before Jekyll runs. So the workflow installs Node, runs gulp copyAll to pull USWDS’s default assets, then gulp compile to build my customized Sass into assets/css/styles.css. Only then does jekyll build run.
The git checkout -- _sass/ line. This one bit me for an embarrassingly long time. gulp copyAll copies USWDS’s default theme files into _sass/, clobbering the customizations I actually want to ship. Restoring _sass/ from git between copyAll and compile puts my theme back in place. If you skip this step your deployed site silently reverts to stock USWDS and you’ll wonder why your fonts look wrong.
SamKirkland/FTP-Deploy-Action. This is the piece of glue that makes the whole thing work. It reads FTP creds from secrets, uploads _site/ to /public_html/, and — critically — is smart enough to diff files so it only uploads what changed.
dangerous-clean-slate: false. The action can optionally wipe the destination folder before uploading. I don’t want that. Shared hosting has other files in public_html/ I don’t want to think about, and a bad deploy shouldn’t be able to nuke the server.
The gotcha worth writing down
The first version of this workflow re-uploaded every file on every deploy. Deploys took four minutes — most of it USWDS static assets that hadn’t changed since I set the site up. Fonts, icons, the compiled USWDS JavaScript. Megabytes of stuff I was FTP-ing over and over for no reason.
The fix is the exclude block:
exclude: |
**/assets/fonts/**
**/assets/img/**
**/assets/js/**
**/assets/uswds/**
Those directories only change when USWDS itself puts out an update — whenever that is. Not my problem today. Excluding them dropped deploys from four minutes to about forty seconds, which felt a little bit like cheating. My compiled CSS and all the HTML pages still get uploaded on every push — because those do change — but the giant static-asset directories get to sit there undisturbed, like furniture I’ve decided not to rearrange.
The tradeoff: when I do upgrade USWDS, I have to temporarily remove the exclude block, push once to sync the new assets, then add it back. I wrote that down in DEPLOYMENT.md in the repo because I know I’ll forget in six months.
Secrets, not files
FTP credentials never touch the repo. Not in .env, not in the workflow file, not anywhere. They live in GitHub Secrets and get injected at build time:
gh secret set FTP_HOST --body 'ftp.ronaldpgarcia.com' --repo ron-garcia/ronaldpgarcia-jekyll
gh secret set FTP_USER --body 'yourusername' --repo ron-garcia/ronaldpgarcia-jekyll
gh secret set FTP_PASS --body 'yourpassword' --repo ron-garcia/ronaldpgarcia-jekyll
Single quotes around the password matter — passwords full of @, !, and $ characters will get mangled by the shell if you use double quotes.
What I’d tell someone starting fresh
- Shared hosting is fine. If you already have it, use it. If you don’t, don’t buy it just for this — but don’t rule it out either. It’s cheap, it’s boring, it works.
- GitHub Actions is the right glue. You get modern deploy ergonomics without giving up the portable output.
- Watch out for the static-asset problem. If your CSS framework ships a lot of files, exclude the ones that don’t change. Otherwise every deploy pays the full FTP tax.
- Keep the workflow file readable. You will not touch it for months at a time. When you come back, past-you should be able to figure out what present-you needs to change without reading three blog posts to remember.
The end result is a personal site that deploys in under a minute, costs a few dollars a month, and doesn’t depend on any single vendor staying in business. That felt like the right foundation for something I want to still exist in ten years.