Almost every team that deploys to its own servers arrives at the same place. You add a deploy job to your CI pipeline, drop in a community SSH action or a hand-written scp command, paste your key into a secret, and it works. The first green checkmark feels like you've solved deployment.
Then the pipeline starts costing you. Not all at once — in small, hard-to-reproduce ways that surface at the worst time, usually during an incident when you most need the deploy path to be boring.
This is a look at why the just SSH in from CI
pattern erodes, using the failure modes teams actually hit, and where a dedicated deployment layer earns its place over a pipeline step. The same brittleness shows up whether you push over SSH or FTP from CI — here we focus on the SSH side.
The pattern that everyone starts with
The canonical setup is a workflow step built on something like appleboy/ssh-action, or a hand-rolled scp/rsync transfer (each with its own tradeoffs between SFTP, SCP, and rsync), triggered on push to main:
- name: Deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /var/www/app
git pull
composer install --no-dev
php artisan migrate --force
Nothing here is wrong on day one. The problem is that this step is quietly doing several jobs at once — authentication, transport, remote execution, release logic, and error handling — with none of them owned by anything that understands deployment. It's a shell script wearing a CI badge.
Failure mode 1: secrets that don't mask cleanly
CI systems mask registered secrets in logs by replacing the exact secret string. That guarantee gets thin the moment your secret is a composite value. A host:port secret, for example, may only be partially redacted — the masker matches the whole string, not the host fragment that also appears elsewhere in output — so part of the value leaks into the log.
Mask more aggressively and you trade one problem for another: if every host string is scrubbed across a multi-server deploy, the logs become ambiguous. You can no longer tell which server produced which line, which is exactly the information you need when one host in the fleet fails and the rest succeed. Coordinating that cleanly — deploying to multiple servers in parallel without version drift — is its own discipline that raw CI logs don't support.
That's the tension. Raw CI logs weren't designed to be a deployment audit trail, and bending them into one fights the masking layer both directions.
Failure mode 2: no concept of a release
git pull && composer install mutates your live directory in place. There is no atomic switch, no discrete artifact, and no clean before
to return to. If the build half-completes — a dependency fetch times out, a migration partially applies — you are now serving a site that is neither the old version nor the new one.
Rollback in this world means another forward deploy: check out the previous SHA, reinstall, re-migrate, and hope the down-migration is as clean as you assumed when you wrote it at 3pm on a calm Tuesday. Teams that lean on this pattern almost always describe rollback as by memory
— there's no retained artifact to flip back to because the model never produced one. Atomic releases with a retained history and a one-click rollback exist precisely because just deploy the old commit again
is not a recovery strategy under pressure.
Failure mode 3: the runner is not a stable machine
Hosted runners are ephemeral and clean, which sounds like a feature until your deploy depends on state. Self-hosted runners are the opposite: they persist, so they accumulate. Leftover SSH agents, a known_hosts file from a decommissioned server, a cached key, half-cleaned working directories — residue from previous runs that makes a deploy behave differently depending on which runner picked it up and what the last job left behind.
The result is the least debuggable class of failure: the pipeline is green on one runner and red on another, with the same commit. You end up debugging the CI environment instead of your application.
Failure mode 4: transport and orchestration are tangled together
A single ssh-action step is authentication, file transfer, remote shell, and release orchestration fused into one opaque block. When it fails, the error surface is a shell exit code and a wall of stdout. Was it the connection? A key permission? A failed composer step on the remote? A timeout mid-transfer? The step can't tell you, because it doesn't model those as distinct phases — it just runs a script and reports whether the script exited non-zero.
Pulling these concerns apart is most of what a real deployment tool does. When deployments run over SSH and SFTP through a system that models them, connection, build, transfer, and remote commands are separate, individually logged steps — so a failure points at a stage instead of a stack trace.
What actually changes when deployment is its own layer
The fix is not write a better bash script.
It's moving the responsibilities that don't belong in a CI job out of it. GitHub Actions is a genuinely good CI system — running tests, building artifacts, gating merges (if that's the part you want to get right, here's how to build a CI/CD pipeline with GitHub Actions). Deployment is a different job with different requirements: credentials that live at the deployment layer instead of in pipeline secrets, per-server logs you can actually read, build steps that run off your production host, and a release model with history and rollback built in. That's the line the DeployHQ vs GitHub Actions comparison draws in detail — CI to prove the code is good, a dedicated deployment platform to ship it safely.
Concretely, that means:
- Credentials and known hosts live in one place, not copied into every repo's secrets, not re-provisioned on ephemeral runners, and not a matter of generating SSH keys from the command line for every new target.
- Build commands run in an isolated environment through a proper build pipeline, so a failed
composer installnever leaves your live server half-updated. - Every server gets its own readable deploy log, so a multi-host deploy tells you exactly which target failed — no log-masking guesswork.
- Releases are atomic and retained, so recovery is flipping back, not re-deriving the previous state — the foundation of atomic, zero-downtime release strategies.
If you've been staring at a red deploy job trying to work out whether it was the network, the key, or the remote build that broke, that ambiguity is the tax. Start with a project and connect your server and the SSH glue stops being something you maintain.
When the CI-only pattern is genuinely fine
To be fair to the humble ssh-action: if you deploy a single static site to a single box, downtime during deploy is acceptable, and you rarely roll back, the pattern is proportionate. Don't add a deployment platform to a personal blog. The calculus changes the moment you have more than one server, a build step that can fail, real users who notice a broken release, or a teammate who needs to understand what shipped without reading raw pipeline output. That's when the deploy step wants to become a deployment system. If you're comparing the options for a single box, the easiest way to deploy on a VPS weighs manual SSH, Capistrano, and managed deploys side by side.
Deploying over SSH from CI isn't a mistake — it's a stage most teams pass through. The mistake is staying there after your setup has outgrown it.
Questions about moving a brittle CI deploy onto a managed path? Email us at support@deployhq.com or reach out on X/Twitter @deployhq.