CI/CD Pipelines with GitHub Actions
Continuous Deployment Strategy
Section titled “Continuous Deployment Strategy”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.
Zero Trust Workflow Integration
Section titled “Zero Trust Workflow Integration”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.
Workflow Breakdown
Section titled “Workflow Breakdown”- Trigger: Activates on
pushevents to themainbranch. - Build: Installs Node.js,
pnpm, and compiles the static site (pnpm run build). - Tailscale: The runner joins the Tailnet with the
tag:ciACL tag. - Deploy: Copies build artifacts to the server via SCP over the Tailscale IP (
100.x.x.x). - Restart: Runs
docker compose up -don the server to pick up new files.
Documentation Repository Workflow
Section titled “Documentation Repository Workflow”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 -dPortfolio Repository Workflow
Section titled “Portfolio Repository Workflow”The portfolio pipeline follows the same Tailscale-first pattern with repo-specific paths:
| Step | Docs repo | Portfolio repo |
|---|---|---|
| Build output | dist/ (Starlight) | dist/ (Astro) |
| SCP target | /root/my-server/docs | /root/my-server/portfolio |
| Artifacts copied | dist/, docker-compose.yml | dist/, docker-compose.yml |
| Post-deploy | docker compose up -d | docker 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.
Security Integration (GitHub Secrets)
Section titled “Security Integration (GitHub Secrets)”No sensitive data is hardcoded into the repository. The following secrets are injected at runtime:
| Secret | Purpose |
|---|---|
TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET | OAuth credentials for ephemeral Tailscale nodes |
SERVER_IP | Internal Tailscale IP of the VPS (100.x.x.x) |
SERVER_USERNAME | SSH user for deployment |
SSH_PRIVATE_KEY | Dedicated ED25519 key for GitHub Actions only |
Validation
Section titled “Validation”- A push to
maintriggers the GitHub Actions workflow and all steps complete successfully. - The Tailscale step shows the runner joining the Tailnet with the
tag:citag. - 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.devorhttps://docs.pablorosi.devwithout manual intervention.