Devops

Don’t Waste Your Money on GitHub Actions—Do This Instead!

Stop overspending on GitHub Actions. Learn how to build a disposable EC2 build server that runs faster, costs pennies, and deploys in minutes.

JS
Jay Simons
Author
Don’t Waste Your Money on GitHub Actions—Do This Instead!

GitHub Actions is convenient, but it’s not cheap. Runner minutes add up quickly, builds are often slow, and you don’t always need the complexity of a full CI/CD platform.

If your main goal is fast, repeatable builds and deployments, there’s a better way: use a disposable EC2 build server that spins up only when you need it, runs your build, and shuts down immediately after.

You get dedicated hardware, predictable builds, and pay just pennies per run.

Here’s how I set it up.


Step 1: Spin Up an Ubuntu EC2 Instance

Launch a new Ubuntu Server in AWS.

  • Instance type:
    • t3.medium works for lighter projects.
    • For serious builds, I use a c7i-flex.2xlarge (~$0.60/hr).
  • Key tip: You’ll only keep it running for minutes at a time, so the hourly rate is irrelevant — builds cost cents.
  • Copy the Instance ID (e.g. i-0123456789abcdef0). You’ll need it for automation.

Step 2: Configure the Instance

SSH in and prepare it for builds.

2.1 Install Nginx (for health checks)

BASH
sudo apt update && sudo apt install -y nginx

Leave the default site running on port 80. We’ll use this later to check when the server is fully booted.


2.2 Install GitHub CLI

BASH
sudo apt install -y gh

This will allow the build user to authenticate with GitHub easily.


2.3 Create a Non-Privileged User

We don’t want to build as root. Create a web user with a home directory:

BASH
sudo adduser --disabled-password --gecos "" web

Switch into it:

BASH
sudo su - web

2.4 Install Node.js and Package Manager

Install NVM (Node Version Manager):

BASH
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

Load NVM into the current shell:

BASH
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

Install Node.js (LTS):

BASH
nvm install --lts

Install your package manager globally (example with pnpm):

BASH
npm install -g pnpm

2.5 Generate SSH Keys

Still as the web user, run:

BASH
ssh-keygen

Press enter through the prompts — defaults are fine.


2.6 Add Build Server’s Key to Deployment Destination

If your builds deploy to another server (e.g. staging or prod), you need to allow the build server to connect.

On the build server:

BASH
cat ~/.ssh/id_rsa.pub

Copy that key and add it to your deployment server’s ~/.ssh/authorized_keys.


2.7 Authenticate with GitHub

Authenticate with GitHub CLI:

BASH
gh auth login

Choose:

  • GitHub.com
  • SSH
  • Paste your token if prompted

Test:

BASH
gh repo list

2.8 Allow Local Machine SSH Access

For convenience, let your local machine connect to the build server without typing a password.

On your local machine:

BASH
cat ~/.ssh/id_rsa.pub

On the build server (as web):

BASH
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys

Paste the key, then:

BASH
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Now you can SSH straight in:

BASH
ssh web@your-server-ip

Step 3: Automate the Local Workflow

Manually starting/stopping the instance is painful. Let’s automate it.

Save this as scripts/remote-build.sh in your repo and make it executable:

BASH
chmod +x scripts/remote-build.sh
BASH
#!/bin/bash
set -euo pipefail

if [ $# -lt 1 ]; then
    echo "Usage: $0 <environment> [--keep-running]"
    exit 1
fi

ENVIRONMENT=$1
KEEP_RUNNING=false
if [ $# -gt 1 ] && [ "$2" == "--keep-running" ]; then
    KEEP_RUNNING=true
fi

# Replace with your details
INSTANCE_ID="YOUR_INSTANCE_ID"
HOSTNAME="your-build-server.example.com"
export AWS_PROFILE=your-aws-profile

echo "Checking for uncommitted git changes..."
if ! git diff-index --quiet HEAD --; then
    echo "❌ Uncommitted changes found."
    exit 1
fi
echo "✅ Working directory clean."

spinner() {
    local pid=$1
    local delay=0.1
    local spinstr='|/-\'
    while kill -0 $pid 2>/dev/null; do
        local temp=${spinstr#?}
        printf " [%c]  " "$spinstr"
        local spinstr=$temp${spinstr%"$temp"}
        sleep $delay
        printf "\b\b\b\b\b\b"
    done
    wait $pid
    return $?
}

STATE=$(aws ec2 describe-instances     --instance-ids $INSTANCE_ID     --query "Reservations[*].Instances[*].State.Name"     --output text)

if [ "$STATE" != "running" ]; then
    echo "▶️ Starting instance..."
    aws ec2 start-instances --instance-ids $INSTANCE_ID >/dev/null
    echo -n "⏳ Waiting..."
    aws ec2 wait instance-running --instance-ids $INSTANCE_ID &
    spinner $!
    echo " done ✅"
fi

PUBLIC_IP=$(aws ec2 describe-instances     --instance-ids $INSTANCE_ID     --query "Reservations[*].Instances[*].PublicIpAddress"     --output text)
echo "🌐 Instance at $PUBLIC_IP"

if [ "$STATE" != "running" ]; then
    echo -n "🔄 Waiting for $HOSTNAME..."
    (
        while ! curl -s -f -o /dev/null "http://$HOSTNAME:80" 2>/dev/null; do
            sleep 2
        done
    ) &
    spinner $!
    echo " ready ✅"
fi

echo "🔄 Syncing .env file..."
scp .env your-ssh-alias:/path/to/project/.env

echo "🛠 Running remote build pipeline..."
ssh -tt your-ssh-alias "cd /path/to/project && pnpm build:remote"

if [ "$KEEP_RUNNING" = true ]; then
    echo "⏸ Leaving instance running."
else
    echo "🛑 Stopping instance..."
    aws ec2 stop-instances --instance-ids $INSTANCE_ID >/dev/null
    echo -n "⏳ Waiting..."
    aws ec2 wait instance-stopped --instance-ids $INSTANCE_ID &
    spinner $!
    echo " stopped ✅"
fi

echo "🎉 Build + deploy complete for '$ENVIRONMENT'."

Step 4: Automate the Remote Workflow

The remote build steps don’t need to live outside your repo. Keep them version-controlled. Within the scripts folder:

BASH
#!/bin/bash
set -euo pipefail

# --- Load Node Version Manager (NVM) ---
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

cd /path/to/project

# --- Checkout correct branch based on environment ---
if [ "$1" = "prod" ]; then
    echo "🔄 Switching to main branch for production..."
    git checkout main
else
    echo "🔄 Switching to dev branch for $1..."
    git checkout dev
fi

# --- Update repository ---
echo "🔄 Pulling latest code..."
git pull

# --- Generate environment variable types (optional, if you use it) ---
echo "🔄 Generating environment types..."
pnpm generate:env-types

# --- Install dependencies ---
echo "📦 Installing dependencies..."
pnpm install --dangerously-allow-all-builds

# --- Build project ---
echo "🏗 Building for $1..."
pnpm build:$1

# --- Deploy project ---
echo "🚀 Deploying to $1..."
pnpm deploy:$1

In package.json, add:

JSON
{
  "scripts": {
    "build:remote": "bash scripts/remote-build.sh"
  }
}

Your scripts/remote-build-steps.sh handles:

  • Checking out the right branch
  • Pulling latest code
  • Installing dependencies
  • Building and deploying

Now every build run uses the version of the script committed in your repo — no drift, no surprises.

Lastly, we need a deploy script:

BASH
#!/bin/bash
set -euo pipefail
# ------------------------------------------------------------
# Deploy Script
# ------------------------------------------------------------
# Stops the app service on the remote host, syncs the latest
# build output and config files, installs dependencies, then
# restarts the service.
#
# Usage:
#   ./deploy.sh
# ------------------------------------------------------------

# --- Config ---
REMOTE_HOST=your-ssh-alias
REMOTE_DIR=/path/to/remote/app
SERVICE_NAME=your-service-name

# --- Stop service ---
ssh $REMOTE_HOST "sudo service $SERVICE_NAME stop"

# --- Sync build output ---
rsync -avzr --delete .next/standalone/ $REMOTE_HOST:$REMOTE_DIR \
  --exclude 'node_modules' \
  --exclude 'pnpm-lock.yaml'

# --- Copy env + package.json ---
scp .env.runtime $REMOTE_HOST:$REMOTE_DIR/.env
scp package.json $REMOTE_HOST:$REMOTE_DIR/package.json

# --- Install + restart ---
ssh $REMOTE_HOST "cd $REMOTE_DIR && pnpm i --dangerously-allow-all-builds && sudo service $SERVICE_NAME start"

# --- Cleanup ---
rm -f .env.runtime

This deploy script handles deployments by stopping the running service, syncing the latest build output and configuration, installing dependencies, and restarting the service. To make this work without constant password prompts, you’ll need to configure NOPASSWD entries in your sudoers file for starting and stopping the app service. This ensures deployments run smoothly and without manual intervention.

Here's an example of what that might look like:

BASH
# Allow user 'web' to manage the app service without password
web ALL=(ALL) NOPASSWD: /usr/sbin/service your-service-name start
web ALL=(ALL) NOPASSWD: /usr/sbin/service your-service-name stop
web ALL=(ALL) NOPASSWD: /usr/sbin/service your-service-name restart

Step 5: Test the Pipeline

Run from your local machine:

BASH
./scripts/remote-build.sh prod

Example output:

TEXT
Checking for uncommitted git changes...
✅ Working directory clean.
▶️ Starting instance...
⏳ Waiting... done ✅
🌐 Instance at 3.88.192.42
🔄 Waiting for build.example.com... ready ✅
🔄 Syncing .env file...
🛠 Running remote build pipeline...
🔄 Checking out main branch...
📦 Installing dependencies...
🏗 Building for prod...
🚀 Deploying to prod...
🛑 Stopping instance...
⏳ Waiting... stopped ✅
🎉 Build + deploy complete for 'prod'.

That’s a full production build and deploy in minutes — and the server shuts itself down when done.


Step 6: Compare Costs

Here’s why this is better than GitHub Actions.

  • GitHub Actions:

    • Linux runners: $0.008/minute
    • A 30-minute build = $0.24
    • 100 builds/month = $24.00
  • EC2 Build Server:

    • c7i-flex.2xlarge: ~$0.60/hour
    • A 10-minute build = $0.10
    • 100 builds/month = $10.00

That’s less than half the cost — and your builds are faster.


Hidden Bonuses

  • Short builds save even more — A 5-minute build is ~$0.05.
  • Next.js Turbopack — With next build --turbopack, builds can be dramatically faster than Webpack, so you’ll pay pennies per run while getting near-instant build feedback.
  • No concurrency limits — Run multiple EC2s if you need parallel builds.
  • Faster cold starts — EC2 boots in ~30–60s, often faster than waiting for Actions to assign a runner.
  • Optional caching — Leave the instance running if you want dependency caches to persist between builds.

Thank You!

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify or SoundCloud!

Please also feel free to check out my Portfolio Site

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.

Comments (0)

Join the discussion and share your thoughts on this post.

💬

No comments yet

Be the first to share your thoughts!

© 2025 Jay Simons. All rights reserved.