Eliminating SSH Dependencies: Migrating to Self-Hosted GitHub Actions Runners for Secure Blue-Green Deployments
Jenkins
Source: Dev.to | Dec 22, 2025 (UTC)
Evolving from SSH-based GitHub Actions to self-hosted runners for enhanced security, performance, and handling both backend API and internal admin frontend deployments
The Problem: Security vs Automation Trade-off
In my previous post Building a Zero-Downtime CI/CD Pipeline: Blue-Green Deployments for 100K+ Daily Requests, I implemented a robust CI/CD pipeline using GitHub Actions with SSH deployment to achieve zero-downtime Blue-Green deployments. The system worked flawlessly for months, handling 100,000+ daily push notifications with 99.97% uptime.
But then we hit a critical security requirement.
Our security audit revealed a significant vulnerability: SSH access was open to the entire internet (0.0.0.0/0). While this made GitHub Actions deployment seamless, it violated security best practices. The mandate was clear: restrict SSH access to our office network only (203.0.113.0/24).
The Immediate Consequence
The morning after implementing the security group restriction:
Run appleboy/ssh-action@v1 ======= CLI Version Information ======= Drone SSH version 1.8.1 ======================================= 2025/11/20 02:23:29 dial tcp ***:***: i/o timeout Error: Process completed with exit code 1.
Enter fullscreen mode
Exit fullscreen mode
Deployment pipeline: completely broken.
GitHub Actions runners operate from dynamic IP addresses across multiple data centers worldwide. Whitelisting GitHub’s IP ranges meant maintaining and updating a list of 100+ CIDR blocks that change periodically—an operational nightmare.
The Dilemma
I faced three options:
Option
Pros
Cons
Keep SSH open
Simple, works
❌ Security risk
Whitelist GitHub IPs
Somewhat secure
❌ Maintenance nightmare, brittle
Self-hosted runner
✅ Secure, no SSH needed
Learning curve
The decision was clear: eliminate SSH dependency entirely by running GitHub Actions directly on the EC2 instance.
Enter the name of the runner group to add this runner to: [press Enter for Default] → [Enter] (use default group) Enter the name of runner: [press Enter for backend-production-runner] → [Enter] (use specified name) This runner will have the following labels: 'self-hosted', 'Linux', 'X64', 'backend', 'production', 'api' → Confirmed Enter name of work folder: [press Enter for _work] → [Enter] (use default)
For the internal admin frontend, repeat the process on the frontend EC2:
# SSH into frontend EC2 ssh -i your-key.pem [email protected] # Create separate runner directory mkdir -p /home/ec2-user/actions-runner-frontend cd /home/ec2-user/actions-runner-frontend # Download and extract runner (same as above) curl -o actions-runner-linux-x64-2.321.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz tar xzf ./actions-runner-linux-x64-2.321.0.tar.gz # Configure with different labels ./config.sh \ --url https://github.com/YOUR_USERNAME/admin-frontend \ --token YOUR_GENERATED_TOKEN_FOR_FRONTEND \ --name frontend-production-runner \ --work _work \ --labels frontend,production,admin
Enter fullscreen mode
Exit fullscreen mode
Step 1.5: Install as System Service (Both Runners)
Critical for production: Configure the runners to start automatically on system boot and restart on failure.
Backend runner:
cd /home/ec2-user/actions-runner-backend sudo ./svc.sh install ec2-user sudo ./svc.sh start sudo ./svc.sh status
Enter fullscreen mode
Exit fullscreen mode
Frontend runner:
cd /home/ec2-user/actions-runner-frontend sudo ./svc.sh install ec2-user sudo ./svc.sh start sudo ./svc.sh status
Enter fullscreen mode
Exit fullscreen mode
Expected output:
● actions.runner.YOUR-ORG-backend-api.backend-production-runner.service Loaded: loaded Active: active (running) since Thu 2025-11-20 11:30:00 KST; 30s ago Main PID: 123456 (runsvc.sh) Status: "Running"
Enter fullscreen mode
Exit fullscreen mode
Key indicator:Active: active (running) confirms the runner is operational.
Step 1.6: Verify Runner Registration
Return to GitHub:
Repository → Settings → Actions → Runners
Enter fullscreen mode
Exit fullscreen mode
You should see:
Self-hosted runners (2) ┌─────────────────────────────────────────────────┐ │ ● backend-production-runner │ │ Idle │ │ Linux X64 │ │ Labels: self-hosted, Linux, X64, backend, │ │ production, api │ │ Last seen: less than a minute ago │ └─────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────┐ │ ● frontend-production-runner │ │ Idle │ │ Linux X64 │ │ Labels: self-hosted, Linux, X64, frontend, │ │ production, admin │ │ Last seen: less than a minute ago │ └─────────────────────────────────────────────────┘
Enter fullscreen mode
Exit fullscreen mode
Status meanings:
● (green dot): Online and ready
Idle: Waiting for jobs
Last seen: less than a minute ago: Healthy connection
Phase 2: Update GitHub Actions Workflows
The workflow changes are remarkably simple—only two modifications needed.
[GitHub Actions UI - Frontend] Run build-and-deploy Runner: frontend-production-runner (self-hosted) ✓ Checkout repository (2s) ✓ Set up Node.js (1s) ✓ Clean and Install dependencies (42s) ✓ Run linter (12s) → Deploy to Production (running...) [EC2 Frontend Terminal] 🚀 Starting Frontend Blue-Green Deployment... 🔍 Checking for dangling images... ✅ No dangling images found. 🎯 Target: admin-frontend-green (port: 8012) 🔨 Building Docker image... 🚀 Starting container admin-frontend-green... 🟡 Health check waiting... (0/30) 🟡 Health check waiting... (5/30) ✅ admin-frontend-green container is healthy 🔄 Switching Nginx configuration... ✅ Nginx reloaded ⏳ Graceful shutdown of old container... 🎉 Deployment complete! admin-frontend-green active on port 8012
Enter fullscreen mode
Exit fullscreen mode
Result: Both deployments flawless. Zero downtime. No SSH connection required.
Performance Comparison
Metric
SSH-based (Part 1)
Self-hosted Runner
Improvement
Backend deployment time
4m 15s
3m 43s
32s faster (12%)
Frontend deployment time
N/A
4m 12s
New capability
Network latency
~200ms (SSH)
0ms (local)
Eliminated
Secrets required
4 (SSH keys)
0
Simplified
Security attack surface
SSH exposed
SSH restricted
✅ Enhanced
Failure points
SSH timeout, network
Runner availability
✅ Reduced
Why faster?
No SSH handshake: Eliminates 200-500ms connection overhead
Local filesystem: Code already checked out by runner
No SSH action overhead: Direct script execution
Parallel capable: Both runners can deploy simultaneously
Security Improvements
Attack Surface Reduction
Before (SSH-based):
Attack Vectors: 1. SSH brute force (port 22 exposed to internet) 2. SSH key compromise in GitHub Secrets 3. Man-in-the-middle on SSH connection 4. GitHub Actions runner IP spoofing Required Secrets: - SSH_HOST (EC2 public IP) - SSH_PORT (22) - SSH_USER (ec2-user) - SSH_KEY (Private key - 2048+ characters) Security Concerns: - Frontend accessible from internet - No IP-based access control - Domain required for all services
Enter fullscreen mode
Exit fullscreen mode
After (Self-hosted runner + IP restrictions):
Attack Vectors: 1. ✅ ELIMINATED: SSH only accessible from office network 2. ✅ ELIMINATED: No SSH keys in GitHub Secrets 3. ✅ ELIMINATED: No SSH connection to intercept 4. ✅ ELIMINATED: Frontend only accessible from office IP 5. Runner authentication via GitHub token (managed by GitHub) Required Secrets: - None (runner token managed locally on EC2) Security Enhancements: - Frontend: IP whitelist at AWS Security Group level - Frontend: IP whitelist at Nginx level (defense in depth) - Backend: Public API with rate limiting - Both: SSH restricted to office network only
After: Zero network-related failures (100+ deployments without incident)
3. Faster Iteration During Development
Deployment testing workflow:
Before:
1. Make code change 2. git push 3. Wait for GitHub Actions 4. GitHub Actions SSH to EC2 5. Wait for deployment 6. Check results Total: ~5 minutes per iteration
Enter fullscreen mode
Exit fullscreen mode
After:
1. Make code change 2. git push 3. Wait for GitHub Actions 4. Runner executes locally 5. Check results Total: ~3.5 minutes per iteration
Enter fullscreen mode
Exit fullscreen mode
Impact: 30% faster feedback loop during deployment script development.
4. Parallel Deployments
New capability: Deploy backend and frontend simultaneously
# Both can run in parallel without conflicts Backend Runner → /home/ec2-user/deploy-backend.sh Frontend Runner → /home/ec2-user/deploy-frontend.sh # Total deployment time: max(backend, frontend) instead of sum
Enter fullscreen mode
Exit fullscreen mode
5. Cost Savings
GitHub Actions minutes:
Plan
SSH-based Usage
Self-hosted Usage
Savings
Free Tier
2,000 min/month
Unlimited
N/A
Paid Plan
$0.008/min
$0/min
100%
Our usage: ~400 minutes/month for builds and deployments (both services).
Cost impact:
SSH-based: Free tier sufficient, but approaching limit
Self-hosted: Zero GitHub Actions minutes consumed for deployment step
Long-term benefit: As team grows, no risk of hitting GitHub Actions limits
Lessons Learned
1. Self-Hosted Runners Are Not “Set and Forget”
Initial assumption: Install runner, it works forever.
Reality: Runners need maintenance:
# Runner updates (every 2-3 months) cd /home/ec2-user/actions-runner-backend sudo ./svc.sh stop ./config.sh remove --token REMOVAL_TOKEN # Download new version ./config.sh --url ... --token NEW_TOKEN sudo ./svc.sh install ec2-user sudo ./svc.sh start # Disk space monitoring (runners accumulate _work artifacts) du -sh /home/ec2-user/actions-runner-*/_work # Output: 4.2G backend, 3.8G frontend (after 3 months) # Cleanup old artifacts (automated in cron) find /home/ec2-user/actions-runner-*/_work -type f -mtime +7 -delete
Compliance: Data must stay within specific infrastructure
Internal tools: Admin dashboards, internal APIs not meant for public
IP-based access control: Services restricted to office network
❌ Not Recommended When
Multiple repositories: Managing runners per repo is tedious (use runner groups)
Varying workloads: Idle runners waste resources (GitHub-hosted scales to zero)
Small team, low frequency: Overhead not worth it (< 10 deployments/month)
No ops expertise: Runner maintenance requires systems knowledge
Highly dynamic IPs: If EC2 IP changes frequently (use ALB or Elastic IP)
Conclusion
Migrating from SSH-based GitHub Actions to self-hosted runners eliminated our deployment’s weakest link: the network dependency and security exposure. By running the CI/CD pipeline directly on our EC2 instances and implementing IP-based access control for our internal admin frontend, we achieved:
Security:
✅ SSH restricted to office network only (203.0.113.0/24)
✅ Zero credentials stored in GitHub Secrets
✅ Reduced attack surface by 75% (no SSH exposure)
✅ Internal admin UI protected by IP whitelist (AWS + Nginx)
✅ Defense in depth: multiple layers of access control
✅ Passed security audit with zero exceptions
Performance:
✅ 15% faster deployments (3m 47s → 3m 12s)
✅ 65% fewer failed deployments (3.2% → 1.1%)
✅ 100% elimination of network-induced failures
✅ 28% reduction in GitHub Actions minutes consumed
✅ Parallel deployment capability for multiple services
Operational:
✅ Simplified troubleshooting (unified logs)
✅ Easier debugging (local execution)
✅ No SSH key rotation headaches
✅ Faster development iteration (30% faster feedback)
✅ Support for both public and internal services
Architecture:
✅ Backend: Public API with domain and SSL
✅ Frontend: IP-only access for internal tools
✅ Clean separation of concerns
✅ Flexible deployment strategy
Cost:
Initial investment: ~8 hours
Ongoing maintenance: ~30-40 minutes/month
ROI: Pays for itself in 1 month
The migration was straightforward, required minimal changes to our existing Blue-Green deployment architecture, and provided immediate benefits. Our 99.98% backend uptime and 99.99% frontend uptime prove that zero-downtime deployments don’t require complex orchestration tools—just thoughtful architecture and the right tool for the job.
Key takeaway: Don’t let network dependencies be your deployment’s Achilles’ heel, and don’t expose internal tools to the public internet. If you control the infrastructure, running CI/CD locally with proper network restrictions is simpler, faster, and vastly more secure than remote execution over SSH.
For further actions, you may consider blocking this person and/or reporting abuse
Залишити відповідь