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.

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)
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
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:
sudo adduser --disabled-password --gecos "" web
Switch into it:
sudo su - web
2.4 Install Node.js and Package Manager
Install NVM (Node Version Manager):
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
Load NVM into the current shell:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
Install Node.js (LTS):
nvm install --lts
Install your package manager globally (example with pnpm):
npm install -g pnpm
2.5 Generate SSH Keys
Still as the web
user, run:
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:
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:
gh auth login
Choose:
- GitHub.com
- SSH
- Paste your token if prompted
Test:
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:
cat ~/.ssh/id_rsa.pub
On the build server (as web
):
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys
Paste the key, then:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
Now you can SSH straight in:
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:
chmod +x scripts/remote-build.sh
#!/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:
#!/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:
{
"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:
#!/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:
# 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:
./scripts/remote-build.sh prod
Example output:
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!