Skip to content
Portfolio

CI/CD Pipelines with GitHub Actions

Manual server updates are prone to human error. GitHub Actions automatically builds and deploys new code every time a commit is pushed to the main branch.

Because the server’s SSH port is blocked from the public internet by the Hetzner firewall, a standard deployment pipeline cannot reach the server over its public IP.


Each pipeline temporarily joins the private Tailscale overlay network. The GitHub runner authenticates with an OAuth client, deploys over the encrypted VPN tunnel as an ephemeral node, and disconnects when the job finishes.

  1. Trigger: Activates on push events to the main branch.
  2. Build: Installs Node.js, pnpm, and compiles the static site (pnpm run build).
  3. Tailscale: The runner joins the Tailnet with the tag:ci ACL tag.
  4. Deploy: Copies build artifacts to the server via SCP over the Tailscale IP (100.x.x.x).
  5. Restart: Runs docker compose up -d on the server to pick up new files.

The docs pipeline in this repository matches the production setup:

# Snippet: .github/workflows/deploy.yml (docs)
- name: Connect to Tailscale
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Copy files to Server via SCP
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_IP }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "dist/,docker-compose.yml"
target: "/root/my-server/docs"
- name: Restart Docker Container via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_IP }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /root/my-server/docs
docker compose up -d

The portfolio pipeline follows the same Tailscale-first pattern with repo-specific paths:

StepDocs repoPortfolio repo
Build outputdist/ (Starlight)dist/ (Astro)
SCP target/root/my-server/docs/root/my-server/portfolio
Artifacts copieddist/, docker-compose.ymldist/, docker-compose.yml
Post-deploydocker compose up -ddocker compose up -d

Both workflows connect to secrets.SERVER_IP, which holds the internal Tailscale IP, not the Hetzner public address. Deploy paths are defined in the workflow files themselves — they are operational layout, not secrets. Access still requires Tailscale authentication and the SSH private key stored in GitHub Secrets.

No sensitive data is hardcoded into the repository. The following secrets are injected at runtime:

SecretPurpose
TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRETOAuth credentials for ephemeral Tailscale nodes
SERVER_IPInternal Tailscale IP of the VPS (100.x.x.x)
SERVER_USERNAMESSH user for deployment
SSH_PRIVATE_KEYDedicated ED25519 key for GitHub Actions only

  • A push to main triggers the GitHub Actions workflow and all steps complete successfully.
  • The Tailscale step shows the runner joining the Tailnet with the tag:ci tag.
  • SCP and SSH steps connect to secrets.SERVER_IP (Tailscale IP), not the public Hetzner address.
  • After deploy, the updated site is live at https://pablorosi.dev or https://docs.pablorosi.dev without manual intervention.