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 runnerdeploy: which runs the actual deployment, running on the self-hosted runner. Thedeployjob depends onverify, so it only runs if the tests pass and the build succeeds.
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 60The 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 60Four 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 fromdocker-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.
- name: deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: docker compose up --build -d --remove-orphans --wait --wait-timeout 60The docker-compose.yml uses bare variable names with no inline values:
services:
app:
environment:
- DATABASE_URL
- API_KEYLocally, 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:
- 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.
- Create a dedicated runner user and add it to the
dockergroup. 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-
Register the runner via GitHub: Settings → Actions → Runners → New self-hosted runner. Follow the instructions as the
github-runneruser. -
Install as a systemd service so the runner starts on reboot:
sudo ./svc.sh install
sudo ./svc.sh startThe 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.
Continue reading
Feb 2026
·5 min read
Apr 2026
·3 min read