How to Set Up VPS Auto-Scaling with Cloud-init and Custom Scripts

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:

  1. Deregister the instance from the load balancer (no new traffic)
  2. Wait for the health check timeout + 10 seconds (in-flight requests complete)
  3. Call the provider API to delete the instance
  4. 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