Automating Magento Deploys — Why I Evaluated GitHub Actions and Walked Away in Favor of a Direct Mac→SSH Setup
A record of seriously evaluating the trendy option (GitHub Actions), dropping it, and settling on running the deploy script directly over SSH from my Mac. The case for a manual-trigger, no-main-repo workflow.
When you run a Magento + Hyvä site, every code change means typing out this long incantation:
maintenance:enable → composer install → setup:upgrade → setup:di:compile
→ tailwind build → clean static → static-content:deploy (9 locales)
→ cache:flush → maintenance:disable
Tack a git pull for each module/theme on the front, and it spills past a full screen. When something you do this often is this long and typed by hand, automation isn't optional — it's mandatory. This post is about how I designed that automation — or more precisely, why I seriously evaluated the trendy approach (GitHub Actions) and then dropped it.
The candidates
Three options came to mind at first: push-triggered deploys via GitHub Actions, building a macOS app, or just a plain SSH script.
The macOS app died quickly. Building an app just to press a single button is something a shell alias or Raycast replaces in one second. That left GitHub Actions vs. a script, and for a while I leaned toward GitHub Actions. It looked cool, after all.
Why I dropped GitHub Actions
As I fleshed out the design, the places where it didn't fit my situation started to show.
First, there's no main repo. The Magento root isn't a single repository — the custom modules are each scattered across their own separate git repos. So you immediately hit the question, "Which repo's push do I hang the trigger on?" Where to even put the workflow YAML never resolves either.
Second, this deploy needs neither an approval gate nor an automatic trigger. Of the value GitHub Actions provides — automatic push triggers, approval gates, a centralized deploy log — none of it is something this workflow needs. There's no approval step to pass through, and the trigger is intentionally manual anyway.
Third, that means GitHub ends up being nothing more than a "relay station that runs one SSH command on my behalf." Issuing a fresh SSH deploy key for that relay, registering GitHub Secrets, and agonizing over where the workflow file goes — that's pure overhead. It amounted to building an approvals system around a single manual deploy.
So I decided to cut GitHub out entirely. The flow simplifies to this:
[Mac] Raycast button
│ ssh -t (direct)
▼
[Server] deploy.sh → git pull each module/theme → build
Two traps I learned along the way
1) An && chain can trap the site in maintenance mode
The original command was shaped like maintenance:enable && composer install && ... && maintenance:disable. The problem: if any intermediate step fails (especially the static deploy looping over 9 locales), the chain halts right there and maintenance:disable never runs. The site is stuck on the maintenance screen.
The fix is to put a trap in the script. Whether it succeeds or fails, disable maintenance mode on exit.
cleanup() {
local code=$?
if [ "$code" -ne 0 ]; then
echo "✖ Failed (exit $code) — disabling maintenance mode"
bin/magento maintenance:disable || true
fi
}
trap cleanup EXITThe hand-typed && chain has no such safety net. Just moving it into a script prevents an entire class of accident.
2) composer update is dangerous in production
I originally used composer update. But update bumps every dependency to the newest version allowed within the composer.json constraints — core or third-party modules can change without warning. A reproducible deploy means composer install (lockfile untouched). When you do need to pull a new version of just your own modules, target it instead of updating everything: composer update yohan/* --with-dependencies.
The workflow doesn't live per-module
This became clear once I straightened out the concept. The deploy "trigger" isn't tied to the source code repos. The thing that actually runs git pull across every module is a single deploy.sh on the server. The array inside it acts as a single registry that says "this site is composed of these modules + these themes."
GIT_DIRS=(
"app/code/Yohan/ModuleA"
"app/code/Yohan/ModuleB"
"app/design/frontend/Hyva/Hyva_K-SALE"
"app/design/frontend/Hyva/Hyva_K-SALE-2"
)
for d in "${GIT_DIRS[@]}"; do git -C "$d" pull --ff-only; doneTen modules, still one trigger. Adding a new module is one more line in this array. Each individual module repo is just a code store — no CI, no workflow needed.
The final shape
On the Mac, a single Raycast Script Command. Pick a site from a dropdown and it ssh -ts into the matching server (so I get the progress log in real time) and runs deploy.sh.
ssh -t "$SSH_HOST" "cd '$REMOTE_PATH' && ./deploy.sh"On the server, a single deploy.sh with a trap and logging. When there are multiple themes, the tailwind build loops over an array, and the static cleanup wipes pub/static/frontend/Hyva wholesale so it's independent of the theme count.
The only prerequisite is "SSH from the Mac reaches that server." I was already using VS Code Remote-SSH, so the key was already there. GitHub Secrets, deploy key issuance, workflow YAML — all gone.
So when do you actually use GitHub Actions?
This isn't a "never use it" verdict. The right time to adopt it is when one of two conditions appears: when multiple people are involved in deploys and you need a record/approval of "who deployed when," or when you want a pipeline that auto-deploys to staging on push. Until then, for a manual-trigger, no-main-repo workflow, a direct connection is the right answer.
The takeaway
The trendy tool isn't automatically the right answer for your situation. GitHub Actions, CI/CD, build artifacts — all good things, but if you don't have the problem they solve (team collaboration, automatic triggers, traceability), they're pure cost. Before following "the textbook," you have to ask whether the problem that textbook solves is even one you have. Bringing a tool to a problem you don't have — overengineering — is the biggest enemy.