Auto-scaling is the holy grail of VPS infrastructure — adding capacity on demand without manual intervention. While cloud platforms offer native auto-scaling groups, you can build a surprisingly robust auto-scaling system for any VPS provider using cloud-init for initialization and custom scripts for orchestration. This guide walks through a complete, production-ready implementation that works with any provider that supports cloud-init and a provisioning API.
What You’ll Need
- A VPS provider that supports cloud-init user data (check our VPS provider comparison table for compatible options)
- A separate “controller” VPS — the smallest plan works; it only runs a lightweight monitoring script
- API access to your VPS provider (for programmatic provisioning and deletion)
- A shared object store (S3-compatible, NFS, or rsync endpoint) for session persistence across instances
- A load balancer — HAProxy or Nginx on the same controller VPS works well
Step 1: Build a Cloud-Init Template
Cloud-init runs on first boot and configures the server automatically. Here’s a template that installs your application stack, writes the service unit, and registers the new instance with your central controller:
#cloud-config
package_update: true
package_upgrade: true
packages:
- nginx
- nodejs
- git
- curl
- fail2ban
runcmd:
- git clone https://github.com/your-org/app-deploy.git /opt/app
- cd /opt/app && npm install
- systemctl enable app.service
- systemctl start app.service
- |
curl -X POST -H "Content-Type: application/json" -d '{"ip": "'$(hostname -I | awk '{print $1}')'", "name": "'$(hostname)'"}' https://controller.internal/register
write_files:
- path: /etc/nginx/sites-available/app
content: |
server {
listen 80;
server_name _;
root /opt/app/public;
location / { proxy_pass http://localhost:3000; }
}
- path: /opt/app/.env
content: |
DB_HOST=db.internal.cluster
REDIS_HOST=redis.internal.cluster
The last runcmd step is critical — it self-registers the new instance with the controller, giving the controller a live list of active backends. Without this, the load balancer has no way to know the new instance exists.
Step 2: The Controller Script (Fixed and Complete)
On your controller VPS, deploy this script. It checks CPU load every 30 seconds. When load exceeds the threshold and the cooldown period has elapsed, it provisions a new instance via the provider API with the cloud-init template attached:
#!/bin/bash
# /usr/local/bin/autoscale-controller.sh
THRESHOLD=75 # Scale up when CPU > 75%
COOLDOWN=120 # Seconds between scale events
MAX_INSTANCES=10
INSTANCES_FILE=/tmp/instances.json
LAST_SCALE=0
scale_up() {
curl -s -X POST -H "Authorization: Bearer $PROVIDER_TOKEN" -H "Content-Type: application/json" -d '{
"region": "us-east",
"plan": "professional-m",
"image": "ubuntu-24-04",
"user_data": "'$(base64 -w0 cloud-init.yaml)'"
}' "https://api.provider.com/v2/instances" | tee -a /var/log/autoscale.log
}
scale_down() {
local lowest_instance
lowest_instance=$(curl -s -H "Authorization: Bearer $PROVIDER_TOKEN" "https://api.provider.com/v2/instances" | jq -r 'sort_by(.created_at) | .[0].id')
if [ -n "$lowest_instance" ] && [ "$lowest_instance" != "null" ]; then
curl -s -X DELETE -H "Authorization: Bearer $PROVIDER_TOKEN" "https://api.provider.com/v2/instances/$lowest_instance"
echo "$(date): Scaled down instance $lowest_instance" >> /var/log/autoscale.log
fi
}
monitor() {
LOAD=$(uptime | awk -F'load average:' '{print $2}' | cut -d, -f1 | tr -d ' ')
INSTANCE_COUNT=$(curl -s -H "Authorization: Bearer $PROVIDER_TOKEN" "https://api.provider.com/v2/instances" | jq length)
NOW=$(date +%s)
if (( $(echo "$LOAD > $THRESHOLD" | bc -l) )) && (( $(echo "$NOW - $LAST_SCALE > $COOLDOWN" | bc -l) )) && (( INSTANCE_COUNT < MAX_INSTANCES )); then
scale_up
LAST_SCALE=$NOW
fi
# Scale down when load drops below 25% for two consecutive checks
if (( $(echo "$LOAD < 25" | bc -l) )) && (( INSTANCE_COUNT > 1 )); then
scale_down
LAST_SCALE=$NOW
fi
}
while true; do
monitor
sleep 30
done
The script now includes both scale-up and scale-down logic with proper arithmetic comparisons. The bc -l calls handle floating-point comparisons correctly, and the cooldown prevents thrashing during load spikes.
Step 3: Load Balancer Integration
Your load balancer (HAProxy, Nginx, or a managed LB) needs to dynamically register new backends. The cloud-init template above calls /register on the controller. The controller then updates the load balancer configuration:
#!/bin/bash
# /usr/local/bin/register-backend.sh
# Called by the controller when a new instance registers
INSTANCE_IP=$1
INSTANCE_NAME=$2
# Add to HAProxy backend
echo " server $INSTANCE_NAME $INSTANCE_IP:3000 check" >> /etc/haproxy/backends.cfg
haproxy -f /etc/haproxy/haproxy.cfg -sf $(cat /var/run/haproxy.pid)
For Nginx, use the ngx_http_upstream_dynamic module or a simple upstream config reload. Keep your LB config in a template directory and regenerate it from the controller’s instance registry on every change.
Step 4: Scale-Down with Connection Draining
Scaling down is trickier than scaling up — you need to drain active connections before terminating an instance. The safe sequence is:
- Deregister the instance from the load balancer (no new traffic)
- Wait for the health check timeout + 10 seconds (in-flight requests complete)
- Call the provider API to delete the instance
- Remove the instance from the monitoring registry
Add a /health endpoint to your application that returns 200 OK normally and 503 Service Unavailable when the instance receives a SIGTERM. The load balancer sees the health check fail and stops routing traffic before the instance is actually terminated:
#!/bin/bash
# Drain connections before shutdown
trap 'echo "Draining connections..."; sleep 10; exit 0' SIGTERM
# Start your app
node /opt/app/server.js
Step 5: Testing Your Auto-Scaling Setup
Use a load testing tool like hey or wrk to simulate traffic and verify that new instances spin up:
# Install hey
go install github.com/rakyll/hey@latest
# Simulate 100 concurrent users making 10,000 requests
hey -n 10000 -c 100 https://your-app.com/
# Watch the controller logs in real time
tail -f /var/log/autoscale.log
New instances should register and start serving traffic within 60–90 seconds of crossing the threshold. If they take longer, check your provider’s API provisioning speed and your cloud-init script for errors in /var/log/cloud-init-output.log.
Choosing the Right VPS Provider for Auto-Scaling
Not all VPS providers are equal when it comes to auto-scaling. You need a provider with:
- Sub-60-second provisioning times — fast spin-up is critical for responsive scaling
- Reliable cloud-init support — test with a manual launch first
- A well-documented provisioning API — REST API with API key auth
- NVMe storage — application boot time drops from 30+ seconds to under 5
- Per-second billing — otherwise scale-downs waste money on the hour boundary
To find the best fit, compare VPS providers on our performance comparison table — we benchmark provisioning speed, API reliability, and cloud-init compatibility across major providers so you can pick the one that fits your auto-scaling requirements.


Leave a Reply
You must be logged in to post a comment.