GitHub Actions deploy to server: SSH deployment, self hosted runners, cost and limits
GitHub Actions can deploy to your own server over SSH, and it is free on public repositories and self hosted runners. What it does not give you is a release model: no atomic switch, no health check that gates the release, and no rollback, so all three are code you write and maintain. Private repositories get 2,000 included Actions minutes a month on GitHub Free and 3,000 on Pro and Team, then billing is per minute. Most teams end up letting Actions run the tests and letting a deployment tool own the release.
Skip the manual setup. DeployManage provisions your server and ships zero-downtime deploys on any cloud.
$ get started freeGitHub Actions will deploy to your own server, and for a lot of projects that is the right call. It is already sitting next to your code, it is free on public repositories, and an SSH action gets you from merge to running code in about twenty lines of YAML.
What surprises people is the second half: Actions has no opinion about what a release is. It will run whatever commands you give it, in whatever order, and it will report a green check mark whether or not your site is actually up afterwards. Atomic switching, health checks and rollback are things you write and then maintain. This post covers the setup that works, the published limits and costs you should know before you commit, and the point at which a dedicated deploy tool saves more time than it costs.
How to use GitHub Actions to deploy
Every server deployment through Actions comes down to the same four decisions: how the runner authenticates to your server, what it copies across, where it puts it, and how the running application picks up the change.
The most common shape is an SSH based job triggered on a push to main:
- Generate a dedicated SSH key pair for deployments. The private half goes into repository secrets, the
public half into
~/.ssh/authorized_keyson the server, restricted to the deploy user. - Check out the code on the runner and build it there:
npm ciand a bundler run, orcomposer install --no-dev --optimize-autoloader. - Copy the built artifact to the server, usually with rsync over SSH, or have the server pull the commit itself using a deploy key.
- Run the post copy commands: migrations, a cache warm, and a reload of the application server.
Two details save most of the pain here. Build on the runner rather than on the server, so a failed
build costs you nothing in production and your server does not need a Node toolchain installed. And use
npm ci rather than npm install, because it installs strictly from the lockfile
into a clean node_modules and will not quietly resolve a different dependency tree than the
one you tested.
GitHub Actions deploy to server via SSH: the key handling that matters
The security model is where hand rolled Actions deployments most often go wrong. The temptation is to paste a developer's existing SSH key into repository secrets because it already works. Do not. That key usually opens every server that person can reach, and it lives in a repository that anyone with write access can read secrets into via a workflow.
A better arrangement takes ten extra minutes:
- Create a deploy user on the server that owns the application directory and nothing else. It does not need a login shell for interactive use and it does not need broad sudo.
- Generate a key pair used only by CI, and add a
command=restriction or at minimum an IP restriction inauthorized_keysif your runners have stable egress. - Pin the host key. Actions runners are fresh machines every time, so a workflow that blindly accepts an unknown host fingerprint will accept anything that answers on that address.
- If the server pulls the code itself instead of receiving it, use a repository deploy key with read only access rather than a personal access token. A deploy key grants one repository to one machine, so revoking it has a blast radius of exactly one.
The same principle applies to your application secrets. They belong in an environment file that lives on the server outside the deployed directory and gets symlinked in, never in the repository and never injected fresh by every workflow run.
What GitHub Actions costs to deploy with
GitHub publishes the numbers, and they are worth checking against your actual deploy frequency before you build a pipeline around them. Usage is free on public repositories and on self hosted runners. For private repositories, the included monthly allowance is 2,000 minutes on GitHub Free, 3,000 on GitHub Pro and GitHub Team, and 50,000 on GitHub Enterprise Cloud.
Past the allowance, standard GitHub hosted Linux runners bill per minute: 0.002 dollars for a 1 core x64 runner, 0.006 dollars for 2 core x64, and 0.005 dollars for 2 core arm64. Those are small numbers per run and unremarkable for a test suite. They stop being unremarkable when a heavy frontend build runs on every push to every branch across a dozen client repositories, which is exactly the agency pattern.
The other published limits worth knowing: a job on a GitHub hosted runner is terminated at 6 hours of execution time, self hosted runner jobs may run up to 5 days, and a workflow run including waiting time is cancelled after 35 days. Concurrent job capacity is 20 on Free, 40 on Pro, 60 on Team and 500 on Enterprise for standard runners. A job queued for a self hosted runner cancels automatically after 24 hours in the queue.
Should I use a self hosted runner?
A self hosted runner is free of minute billing and can sit inside your network, which solves two real problems: cost on a busy private repository, and deploying to a server that has no public SSH exposure at all. The runner reaches out to GitHub, so nothing inbound has to be opened.
The tradeoff is that you now operate the runner. It is a long lived machine with your source code, your build dependencies and your deploy credentials on it, and GitHub is explicit that self hosted runners should not be used with public repositories, because anyone opening a pull request could execute code on your infrastructure. For a private repository behind a firewall, a self hosted runner is a reasonable piece of infrastructure. For a public one, it is a liability.
GitHub Actions deploy to multiple servers
A matrix strategy makes this look easy: list your hosts, and the same deploy job runs against each one in parallel. The problem is that parallel jobs finish at slightly different times, so for a few seconds your load balancer is sending traffic to servers running two different versions of the application. If your schema changed, some of those requests fail.
The fix is to split the work. Do the slow part everywhere first, which means copying and building the new release directory on every server while the current version keeps serving. Then, in a second job that depends on the first, flip every server to the new release in quick succession and reload. Even then, your database schema has to tolerate both versions running at once for the length of the rollout. Adding a nullable column is fine. Renaming one in the same release that starts using the new name is not, and no amount of pipeline cleverness fixes it.
What Actions does not give you
Here is the honest list of what you are signing up to build and keep working, because none of it is included:
- An atomic switch. Copying files into the live directory means requests during the copy see a half updated application. Getting this right means release directories and a symlink flip.
- A health check that gates the release. A workflow succeeding means the commands exited zero, not that your site returns 200. Those are different facts.
- Rollback. Re-running an older workflow rebuilds from an older commit, which takes minutes and can fail for entirely new reasons. That is not the same as repointing a symlink at a release that was demonstrably working sixty seconds ago.
- The server itself. Actions deploys to a machine that someone still has to provision, firewall, patch, give a TLS certificate, and configure with a process manager and a queue worker.
None of these are hard individually. The cost is that they become a bespoke system that only works until the person who wrote the YAML leaves. Whichever route you take, put an external check on the URL from outside your own network as well, because a pipeline that reports success while the site returns 500 is the failure mode that hurts most.
When a deployment tool is the better answer
The split most teams settle on is simple: GitHub Actions runs the tests, and something else owns the release. Actions is genuinely excellent at continuous integration. It gates the merge, it runs the suite on every pull request, and it does that on infrastructure you never think about.
The release is a different job with different requirements, and it is worth using a tool that treats a release as a first class thing rather than as a sequence of shell commands. That is what deploying from Git with a deployment tool gives you: the commit is checked out into a fresh release directory, dependencies install and assets build there while the current version keeps serving traffic, a symlink flip promotes it atomically, and a failed health check puts the previous release back without anyone being paged.
DeployManage also provisions the server the release lands on, which is the piece Actions never touches. If you want the mechanics of the release model itself, we cover it in zero downtime deployment explained, and the deployment automation page compares the wider category including Jenkins, Ansible and Kubernetes tooling. The same gap shows up on the other side of the fence: a pipeline that deploys to a server from GitLab CI/CD has to write the release layer by hand too.
A reasonable default
If you are starting today and the project earns money, the setup that costs the least over a year looks
like this. Let GitHub Actions run your tests on every pull request and block the merge when they fail.
Let a deployment tool watch main and ship the release, with health checks and rollback that
you did not have to write. Keep secrets in a shared environment file on the server, keep repository access
on read only deploy keys, and make your migrations backward compatible so a rollback is always available.
That leaves you maintaining zero lines of deployment YAML, which is the number you want when it is 6pm on a Friday and something needs to go back.
Ready to stop managing servers by hand? DeployManage provisions, deploys and monitors your fleet from one dashboard.
$ get started freeFrequently asked questions
How do I use GitHub Actions to deploy to a server?
Trigger a workflow on push to your deploy branch, build the application on the runner, then copy the result to the server over SSH with rsync or have the server pull the commit with a deploy key. Finish with migrations and an application reload. Store the SSH private key in repository secrets, never a developer's personal key.
How much does GitHub Actions cost?
Usage is free on public repositories and self hosted runners. Private repositories include 2,000 minutes a month on GitHub Free, 3,000 on GitHub Pro and Team, and 50,000 on Enterprise Cloud. Past the allowance, standard Linux runners bill 0.002 dollars a minute for 1 core and 0.006 dollars a minute for 2 core x64.
What is the GitHub Actions time limit?
A job on a GitHub hosted runner is terminated after 6 hours of execution time. Jobs on self hosted runners can run up to 5 days. A workflow run including queue and wait time is cancelled after 35 days, and a job waiting for a self hosted runner cancels after 24 hours in the queue.
Should I use GitHub Actions or a deployment tool?
Use GitHub Actions for continuous integration and a deployment tool for the release. Actions is excellent at running tests and building artifacts, but it has no concept of a release, so atomic switching, health checks and rollback are yours to build. Running both is the common answer.
Can GitHub Actions deploy to a private server?
Yes. Either allow SSH from GitHub's runner IP ranges, or install a self hosted runner inside your network so the connection is outbound only and nothing inbound has to be exposed. The self hosted route is the usual answer for servers with no public SSH.
Is a self hosted runner safe?
On a private repository it is a reasonable piece of infrastructure, as long as you treat it as a machine holding source code and deploy credentials. GitHub recommends against self hosted runners on public repositories, because anyone opening a pull request could run code on your hardware.
How do I deploy to multiple servers with GitHub Actions?
Build the new release on every server in a first job, then use a dependent job to switch all servers to it in quick succession and reload. Deploying servers fully in parallel leaves your load balancer serving two versions at once, which fails whenever the database schema changed.
Does deploying with GitHub Actions cause downtime?
It does if the workflow copies files over the live directory, because requests during the copy hit a partially updated application. It does not if you build each release in its own directory and promote it with an atomic symlink switch, but Actions does not do that for you by default.