Writing

Automated deployments to a VPS with GitHub Actions and Docker Compose

How I set up automated deployments to a VPS without a public IP address, using a self-hosted GitHub Actions runner.

12 Apr 2026

·

4 min read

·
DevOpsDockerGitHub ActionsDeployment

Share

I've been running a Docker Compose app on a VPS and wanted a simple and free way to set up automated deployments to it. The obvious approach is to SSH into the server from a GitHub-hosted runner, however my VPS doesn't have a public IP address and I didn't want to open my SSH port to the internet.

The alternative is a GitHub Actions self-hosted runner installed directly on the VPS. The runner connects outbound to GitHub over HTTPS, so no inbound firewall rules are needed. When a workflow job targets runs-on: self-hosted, it runs directly on the server, able to directly access Docker and the app files without SSH or extra secrets.

The two-job workflow

The workflow splits into two jobs:

  • verify: which runs tests and builds the software, running on a GitHub-hosted runner
  • deploy: which runs the actual deployment, running on the self-hosted runner. The deploy job depends on verify, so it only runs if the tests pass and the build succeeds.
.github/workflows/deploy.yml
name: deploy

on:
  push:
    branches: [main]
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: install
        run: bun install --frozen-lockfile
      - name: lint
        run: bun lint
      - name: typecheck
        run: bun run typecheck
      - name: test
        run: bun test

  deploy:
    runs-on: self-hosted
    needs: verify
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/checkout@v6
      - name: deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: docker compose up --build -d --remove-orphans --wait --wait-timeout 60

The if: condition on deploy is important. Without it, every PR would attempt a deploy once verify passed. The needs: verify ensures the gate, but the if: ensures the job only runs at all on a push to main.

The checkout action handles the clone, in a way that also lets be rollback to previous commits by re-running the old workflow.

Docker Compose does the rest

Once the code is on the server, one command handles the deploy:

- name: deploy
  run: docker compose up --build -d --remove-orphans --wait --wait-timeout 60

Four flags worth explaining:

  • --build: rebuilds the image from the freshly checked-out code. Without it, Compose would reuse the cached image from the last deploy.
  • -d: runs detached so the workflow step doesn't block waiting for containers.
  • --remove-orphans: removes containers for services that have been deleted from docker-compose.yml. Without it, removed services keep running indefinitely.
  • --wait: blocks until the container's healthcheck reports healthy. Without it, the step exits as soon as the containers start, so a broken app returns success. Combined with --wait-timeout 60, a stuck container fails the step after 60 seconds rather than hanging the runner indefinitely.

Secrets without a .env file

The deploy step passes secrets via env:, which Compose inherits from the shell. No .env file is needed in CI, and no secrets are written to disk on the server during a deploy.

.github/workflows/deploy.yml
- name: deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    API_KEY: ${{ secrets.API_KEY }}
  run: docker compose up --build -d --remove-orphans --wait --wait-timeout 60

The docker-compose.yml uses bare variable names with no inline values:

docker-compose.yml
services:
  app:
    environment:
      - DATABASE_URL
      - API_KEY

Locally, Compose auto-loads .env from the project directory, so the development workflow is unchanged. The .env file never goes near CI.

VPS setup

Before the first deploy, the server needs a few things in place:

  1. Install Docker and Git, which is needed for the runner and the deploy command. The runner needs Docker to run the app, and Git to do the checkout.
  2. Create a dedicated runner user and add it to the docker group. You can set it up to use your own user but its better to create a dedicated user for the runner
useradd -m github-runner
usermod -aG docker github-runner
  1. Register the runner via GitHub: Settings → Actions → Runners → New self-hosted runner. Follow the instructions as the github-runner user.

  2. Install as a systemd service so the runner starts on reboot:

sudo ./svc.sh install
sudo ./svc.sh start

The GitHub Actions runner tarball ships a svc.sh script that handles the systemd service setup. The runner connects outbound to GitHub on startup and stays connected, waiting for jobs.

Wrapping up

The core idea here is that a self-hosted runner turns GitHub Actions into a deploy mechanism without needing SSH, extra secrets or third-party actions. The runner is on the server, checkout puts the code in place and Docker Compose handles the rest.