MLOpsThe MLOps lifecycle · MLflow 3.17 · DVC 3.67 · FastAPI · Docker · GitHub Actions · Evidently · Python 3.11+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
50 small wins to finish your pathNext lesson →

Deploying with a self-hosted runner

A self-hosted runner is a machine you own and manage that runs the GitHub Actions runner application, so a job of your workflow runs on your own server instead of on a machine GitHub provides and then discards.

Last updated: 19 Oct, 2026 · MLOps

After Building the image in CI the new image is in the registry, and the server is still running the old one. Something has to pull the new tag and restart the container, and a GitHub-hosted runner cannot: it is a machine somewhere else, with no way into your network. Running the deploy job on the server itself solves that, and this is the pattern the deployment videos use for an EC2 instance.

A diagram with GitHub, holding the repository and the job queue, at the top and two runners beneath it. On the left, a GitHub-hosted runner: GitHub starts a clean machine for the job, it is chosen with runs-on ubuntu-latest, it is thrown away when the job ends, it reaches the internet so it can push an image, and it cannot reach a server in your own network. On the right, a self-hosted runner: a machine you own runs the runner agent, the agent asks GitHub for work so no inbound port is opened on your server, it is chosen with runs-on self-hosted, and it is the deploy target itself because it pulls and runs the image. A red warning says a self-hosted runner on a public repository is dangerous, because anyone can open a pull request and a workflow that runs its code runs it on your machine.

How the runner reaches GitHub

  • The agent asks for work. The runner application on your server opens the connection to GitHub and waits for a job, so no port has to be opened on the server for GitHub to reach in.
  • The machine is yours. You choose the hardware, the operating system and what is installed, and you keep all of it updated. For this pipeline it needs Docker, and the runner's user has to be allowed to use it.
  • Nothing is cleaned up for you. A self-hosted runner does not start from a clean image for every job, so files, images and containers from the last run are still there. That is what makes it a deploy target, and also what makes an untrusted job on it dangerous.
Registering an EC2 box as a self-hosted runner, and seeing it go idle · from the Complete End to End Deep Learning Project With MLFLOW,DVC And Deployment · 3:56:04 to 3:57:08

The clip registers an EC2 machine against the deep-learning project's repository; here you register whichever server runs the student-performance container, and the Runners page, config.sh and run.sh are the same three steps.

Registering the runner on your server

The repository's Settings page, under Actions and then Runners, generates the exact commands for the operating system and architecture you pick, including the download URL and a registration token that expires after an hour. The shape is always the same four steps, described in Adding self-hosted runners.

bash
# on the server, as the user the runner will run as
mkdir actions-runner && cd actions-runner

# the exact download URL and token come from the repository's
# Settings > Actions > Runners > New self-hosted runner page
curl -o actions-runner-linux-x64.tar.gz -L <URL FROM THAT PAGE>
tar xzf ./actions-runner-linux-x64.tar.gz

# the token is generated for you and expires after one hour
./config.sh --url https://github.com/OWNER/REPO --token <REGISTRATION TOKEN>

config.sh asks for a runner group, a name and labels. The name is how the runner appears in the Runners list; the labels are how a job asks for it. Then the application has to keep running, which is what installing it as a service does, so it survives a reboot and a closed terminal.

bash
# run it once in the foreground to watch it connect, then stop it
./run.sh

# or install it as a service so it starts with the machine
sudo ./svc.sh install
sudo ./svc.sh start

A connected runner shows as Idle in the repository's Runners list. Idle means the agent is connected and waiting; a job that asks for it starts within seconds of the push.

Pointing the deploy job at your server

runs-on: self-hosted sends the job to a runner carrying the self-hosted label, which every self-hosted runner has. needs keeps it behind the build, so the image it pulls is the one this run pushed.

yaml
  deploy:
    needs: build-and-push
    runs-on: self-hosted

Replacing the running container

The steps are the commands you would type on the server, in the order you would type them: pull the new image, remove the old container, start a new one from the new image, and check that it answers.

yaml
      - name: Pull the image this run built
        run: docker pull ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest

      - name: Replace the running container
        run: |
          docker rm -f student-performance || true
          docker run -d --name student-performance -p 8000:8000 \
            ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest
  • docker rm -f stops and removes the old container in one command, and || true keeps the step from failing on the first deploy, when there is nothing to remove.
  • The port mapping is repeated on every deploy, because a container keeps no settings from the one it replaced.
  • A health check as the next step turns a container that starts and dies into a failed job. curl -fsS returns a non-zero exit code on an error response, and a non-zero exit code fails the step.

The deploy job in one piece

yaml
  deploy:
    needs: build-and-push            # the image must exist before it is pulled
    runs-on: self-hosted             # this job runs on your own server
    steps:
      - name: Pull the image this run built
        run: docker pull ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest

      # the old container still holds port 8000, so it goes before the new one starts
      - name: Replace the running container
        run: |
          docker rm -f student-performance || true
          docker run -d --name student-performance -p 8000:8000 \
            ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest

      - name: Check the service answers
        run: curl -fsS http://localhost:8000/health

      - name: Remove the images the new one replaced
        run: docker image prune -f

A hosted runner against a self-hosted one

GitHub-hostedSelf-hosted
Who provides the machineGitHubYou
State between jobsNone, the machine is newEverything the last job left
Reaching your private serverNoIt is the server
What it costsIncluded minutes, then per minuteThe machine, and keeping it patched
Chosen withruns-on: ubuntu-latestruns-on: self-hosted
Watch out. The GitHub documentation says self-hosted runners should almost never be used with public repositories, because anyone can open a pull request and a workflow that runs the pull request's code would run that code on your server, with the files and credentials the runner's user can reach. Keep self-hosted runners on private repositories.
Try it yourself
  • Stop the runner application on the server, push a commit, and watch the deploy job sit in the queue.
  • Add a label of your own in config.sh and change runs-on to [self-hosted, that-label].
  • Break the health check by mapping the wrong port and read which step turns the run red.

You understood something today that you didn't yesterday.