What is Infrastructure as a Service? The Layer That Makes Everything Else Possible

Infrastructure as a Service gives you virtualized servers, storage, and networking on demand without owning any hardware. You rent compute and own everything that runs on it. Here is what IaaS actually is, what it demands from you.

In 2008, I was a Systems Administrator and PHP developer at a web hosting company called SolidShellSecurity. My job, in plain terms, was to keep servers alive and build things on top of them for clients. Physical servers. Real machines in real data centers drawing real power with real consequences when they went down at 3 AM.

I understood what infrastructure meant because I was responsible for it. When a hard drive failed, that was our problem. When network capacity was saturated, that was our problem. When a client's traffic spiked and their shared server started throttling everyone else on it — that was absolutely our problem, and also their problem, and also the problem of every other customer on that box who suddenly had a slow website.

That was the era before Infrastructure as a Service had matured into what it is today. You either owned hardware, leased it in a colocation facility, or you bought shared hosting and accepted that you had zero control over what was underneath you. The middle ground — virtualized, on-demand compute that you could spin up in minutes without buying a physical machine — existed but was not yet the obvious default it has since become.

That changed. Dramatically. And if you do not understand what IaaS is and what it actually gives you, you are missing the foundation that most of the modern internet runs on.

The Problem IaaS Solves

Before you understand IaaS, you need to understand what was broken before it.

The old model of running software in production had two options that were both painful in different ways.

Option one: Buy your own servers. You spec a machine, order it, wait for delivery, rack it in a data center, negotiate a colocation contract, configure networking, install an operating system, and then — weeks or months after deciding you needed more capacity — you finally have something you can deploy to. You have also bought a fixed amount of hardware that will be either over- or under-provisioned almost immediately because traffic is not predictable. When the hardware fails, and it will, you own that problem entirely.

Option two: Shared hosting. Pay a few dollars a month, get a slice of a server someone else owns and manages. Easy, cheap, zero control. Your MySQL database sits next to fifty other databases on the same machine. Your PHP application shares CPU with every other account on the host. When another tenant's site gets a traffic spike or runs a bad query, you feel it. No root access. No custom configuration. You get what you get.

Infrastructure as a Service is the answer to both of those problems. It gives you virtualized compute resources — servers, networking, storage — that you provision on demand, pay for by usage, and have full administrative control over, without owning or managing any physical hardware. Somebody else owns the physical machines. You rent a virtualized slice of them and treat it like it is yours.

That is IaaS. The physical infrastructure — the servers, the network switches, the storage arrays, the power, the cooling, the data center — all managed by the provider. What you get is a virtual machine where you have root access, you choose the operating system, you install what you want, and you control everything above the hypervisor layer.

Where It Sits in the Stack

IaaS is the bottom of the cloud computing stack. The provider gives you virtualized hardware and says: "Here is a machine. Do whatever you want with it." Everything else — the operating system, the web server, the database, the application runtime, the deployment process, the monitoring — that is your responsibility.

The three tiers in plain terms:

SaaS  →  You use software      (Gmail, Slack, Notion, GitHub)
PaaS  →  You deploy code       (Heroku, Railway, Render, Fly.io)
IaaS  →  You manage servers    (AWS EC2, DigitalOcean, Hetzner)

Software as a Service is the top: fully managed software delivered over the internet where the vendor handles everything. Platform as a Service sits in the middle: the provider manages the OS and runtime, you deploy application code. IaaS is the bottom: you get the VM, you own everything above the hypervisor.

At SolidShellSecurity, we were essentially operating as an IaaS-adjacent provider before the term was common — giving clients managed server environments where they had control over their stack. The difference was we were managing physical machines in a data center, which is why the operational burden was so much heavier than renting virtual machines from a cloud provider would have been.

What a VPS Actually Looks Like

The most common IaaS product you will encounter as a developer is a VPS — a Virtual Private Server. A virtualized machine running on shared physical hardware, giving you root access and full control without the cost of dedicated hardware.

I made the jump from PaaS to VPS firsthand when a Discord bot I was working on outgrew Heroku. Heroku was fine at small scale — zero ops overhead, just deploy and run. But once the costs started compounding with growth, the math stopped making sense. A VPS handled the same load for a fraction of the price. If you want the full story on that migration and what it actually took to scale that bot, go read What is Python — it covers the whole thing.

What running on IaaS actually demands from you once you are on that VPS:

# System updates — on PaaS this is the provider's problem
# On IaaS it is yours, and ignoring it means unpatched vulnerabilities
sudo apt update && sudo apt upgrade -y
 
# Firewall configuration — on PaaS this is abstracted away
# On IaaS you own the network security layer entirely
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP
sudo ufw allow 443/tcp   # HTTPS
sudo ufw enable
 
# Fail2ban to block brute force attempts on SSH
sudo apt install fail2ban -y
sudo systemctl enable --now fail2ban
 
# Checking memory because you have a fixed amount
# and if you run out, the kernel starts killing processes
free -h
 
# Watching disk usage because running out kills databases
df -h

None of this is hard. All of it is necessary. When you are on IaaS, the server does not manage itself. That is the explicit trade you made for control.

Setting Up a VPS From Scratch

A fresh VPS needs hardening before you put anything on it. Here is the minimal sequence I run on every new server:

# Step 1: Create a non-root user with sudo privileges
adduser deploy
usermod -aG sudo deploy
 
# Step 2: Copy SSH public key to the new user
# Run this from your LOCAL machine, not the server
ssh-copy-id deploy@YOUR_SERVER_IP
 
# Step 3: Disable root SSH login and password authentication
sudo nano /etc/ssh/sshd_config
# Changes to make in sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
sudo systemctl restart sshd
 
# Step 4: Full firewall setup
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
sudo ufw status verbose
 
# Step 5: Fail2ban for automated brute force blocking
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
[sshd]
enabled  = true
port     = ssh
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 5
bantime  = 3600
findtime = 600
sudo systemctl enable --now fail2ban
 
# Step 6: Automatic security updates
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
 
# Step 7: Set timezone, hostname, locale
sudo timedatectl set-timezone UTC
sudo hostnamectl set-hostname my-production-server
 
# Step 8: Swap file for servers with limited RAM
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

That is the baseline. Every server you spin up should go through something like that before you put an application on it.

What IaaS Actually Gives You

The core product across every IaaS provider is a virtual machine, but the full picture includes several components that matter for real deployments.

Compute. The VM itself. You choose the size: CPU cores, RAM, base OS. Ubuntu 22.04 LTS, Debian 12, AlmaLinux — your call. Root access. Yours.

Block Storage. Persistent disk attached to your VM. You format it, mount it, use it for databases or file uploads. On DigitalOcean these are Volumes. On AWS they are EBS (Elastic Block Store). When you destroy the VM, block storage can persist and attach to a new machine — critical for stateful workloads like databases.

Object Storage. For files and static assets that do not need to sit on a local disk. AWS S3 is the canonical example. Images, backups, build artifacts, static website hosting — anything that needs to survive beyond a single server's lifetime and be accessible globally. Every major IaaS provider has an S3-compatible object storage product.

Networking. Private networks between your servers, load balancers, firewalls, IP address management. On any serious deployment you will use a private network so your web servers talk to your database servers without that traffic leaving the data center. Publicly exposing PostgreSQL or Redis to the internet is a disaster waiting to happen.

Snapshots and Backups. Point-in-time images of your server's disk state. Configure automated daily snapshots the day you provision a server, not after something goes wrong. Restoring from a three-day-old backup because you forgot to enable automated snapshots is a lesson that sticks.

DNS Management. Most IaaS providers include DNS management. Pointing your domain at your servers, managing A records and CNAMEs, setting TTLs — all manageable through the same console where you manage your VMs.

The Major Providers

AWS EC2 — The Enterprise Default

Amazon Web Services launched EC2 in 2006 and has been the dominant IaaS provider ever since. The service breadth is unmatched: EC2 for compute, S3 for object storage, RDS for managed databases, Lambda for serverless, CloudFront for CDN, Route 53 for DNS, EKS for managed Kubernetes, and hundreds of additional services that integrate with each other through IAM roles and VPC networking.

AWS makes sense when you need that ecosystem. If your Node.js application needs to process images uploaded to S3, trigger Lambda functions, and store results in DynamoDB, AWS's native integration between services is genuinely valuable. IAM roles allowing EC2 instances to access S3 buckets without storing credentials on the server — that works cleanly.

The tradeoffs are complexity and pricing opacity. AWS pricing is famously hard to predict. EC2 instance pricing varies by region, instance type, and whether you are on on-demand, reserved, or spot pricing. Data transfer costs, NAT gateway costs, load balancer costs — your bill at month end can surprise you. For enterprises that already have AWS footprint and need the full ecosystem, it is the obvious choice. For a startup running a Python API and a PostgreSQL database, it is probably overkill.

DigitalOcean — Developer-Friendly IaaS

DigitalOcean built its reputation on being the IaaS for developers who do not want to be cloud architects. Their product is Droplets — Linux VMs with clear, predictable pricing starting around $4–6/month for the smallest tier.

The control panel is clean. Documentation is excellent. Spin up an Ubuntu server in under a minute. No deciphering a pricing calculator. No hunting through a console designed to accommodate 600 different services.

For the Discord bot, once we needed a persistent long-running process rather than the stateless request-response model Heroku provides, DigitalOcean was the landing spot. Managed Kubernetes (DOKS), managed PostgreSQL and Redis databases, object storage (Spaces, which is S3-compatible) — all available without the AWS learning curve and with billing that does not produce surprises.

Hetzner — European Value

Hetzner is a German provider that has become popular in developer communities for offering hardware that significantly outprices DigitalOcean. A machine with 4 vCPUs and 8GB RAM runs around €4–5/month on Hetzner — a configuration that costs three to four times that on DigitalOcean.

The tradeoff is data center locations concentrated in Europe and the US East Coast. If you are serving European users or your compliance requirements favor EU data residency, Hetzner deserves serious consideration. If you need West Coast US latency for North American users, you are limited. For self-hosted projects, homelab adjacent workloads, and European-facing services, Hetzner's price-to-performance ratio is genuinely hard to beat.

Linode / Akamai — Performance and Reliability

Linode (now branded as Akamai Cloud after the 2022 acquisition) was the other dominant player in the developer IaaS market alongside DigitalOcean. Pricing is comparable — around $5/month for a 1GB RAM, 1 vCPU, 25GB SSD instance with 1TB transfer included.

Linode has a strong reputation for raw performance on I/O-heavy workloads, particularly databases. If you are running MySQL or PostgreSQL on IaaS on a constrained budget, benchmarks consistently show Linode competitive for database workloads. The documentation is good. Support is responsive. A solid alternative to DigitalOcean with slightly better database I/O characteristics.

Vultr — Global Footprint

Vultr occupies a similar space to DigitalOcean and Linode with a broader selection of global data center locations — 30+ locations including Asia Pacific cities that neither DigitalOcean nor Hetzner cover well. For applications where latency to a specific region matters — a gaming server for a specific player base, a regional API endpoint — Vultr's geographic spread can be the deciding factor.

What You Are Actually Responsible For

This is the list that PaaS advocates use to argue against IaaS. They are not wrong — the operational list is real and it belongs to you.

Operating System Maintenance

Security patches, kernel updates, package management. apt update && apt upgrade is not something you run once at setup. Unpatched servers accumulate CVEs. This is how otherwise well-written applications get compromised — not through a flaw in the application code, but through a known vulnerability in an old version of OpenSSL or the Linux kernel that was never patched.

Enable automatic security updates:

sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
 
# Verify configuration
sudo cat /etc/apt/apt.conf.d/50unattended-upgrades

Unattended upgrades handle security patches automatically. You still need to handle major version upgrades manually — but the long tail of critical CVEs that get exploited in the wild are security patches to existing packages, and those run automatically.

Service Configuration

Your Nginx configuration, your PostgreSQL tuning, your Redis maxmemory settings, your systemd service definitions — none of this is set up for you. Here is the systemd unit you write once but use everywhere:

# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target postgresql.service redis.service
Wants=postgresql.service redis.service
 
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
Environment=NODE_ENV=production
EnvironmentFile=/etc/myapp/env
 
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp
journalctl -u myapp -f

Systemd handles restart-on-crash, dependency ordering (start after PostgreSQL), and log aggregation through journald. Every application on a VPS should run as a systemd service, not as a nohup process in a screen session.

Application Deployment

How does new code get to the server? You need a deliberate, repeatable deployment process. The first deployment process is usually SSH in and git pull manually. That stops working the moment you have two servers or a team member who needs to deploy.

A minimal but real zero-downtime deployment script:

#!/bin/bash
set -euo pipefail
 
SERVER="deploy@your-server-ip"
APP_DIR="/var/www/myapp"
RELEASE_DIR="${APP_DIR}/releases/$(date +%Y%m%d_%H%M%S)"
CURRENT_LINK="${APP_DIR}/current"
REPO="https://github.com/company/myapp.git"
BRANCH="${1:-main}"
 
log() { echo "[$(date '+%H:%M:%S')] $*"; }
 
log "Deploying branch: $BRANCH"
 
ssh "$SERVER" "
    set -e
    mkdir -p $RELEASE_DIR
    git clone --depth 1 --branch $BRANCH $REPO $RELEASE_DIR
    cd $RELEASE_DIR
    npm ci --only=production
    npm run build
    ln -sfn $RELEASE_DIR $CURRENT_LINK
    sudo systemctl reload myapp || sudo systemctl restart myapp
    ls -1dt ${APP_DIR}/releases/* | tail -n +6 | xargs rm -rf
    echo 'Deploy complete'
"
 
log "Deployment finished"

The symlink swap (ln -sfn) makes the cutover atomic — Nginx is reading from /var/www/myapp/current which instantaneously points to the new release. Old releases are pruned to the last five. Systemd reload applies the new code without dropping connections.

Monitoring and Alerting

When does your disk hit 90%? When does your database run out of connections? When does the server go down at 3 AM? On PaaS, the provider monitors the platform. On IaaS, you monitor your application and your system.

A minimal monitoring stack for a VPS:

# Netdata for real-time system metrics — installs in one command
bash <(curl -Ss https://my-netdata.io/kickstart.sh)
 
# Access at http://your-server-ip:19999
# Shows CPU, RAM, disk, network, PostgreSQL stats, Nginx connections
# All in real-time with a 1-second granularity
 
# UptimeRobot (free tier) for external HTTP monitoring
# Point it at https://yourdomain.com/health/live
# Alerts by email/SMS when the URL stops responding

For a production application, add Prometheus with Node Exporter and a Grafana dashboard:

# Install Node Exporter on your server
wget https://github.com/prometheus/node_exporter/releases/download/v1.7.0/node_exporter-1.7.0.linux-amd64.tar.gz
tar xvf node_exporter-1.7.0.linux-amd64.tar.gz
sudo cp node_exporter-1.7.0.linux-amd64/node_exporter /usr/local/bin/
 
# Run as systemd service
sudo tee /etc/systemd/system/node_exporter.service > /dev/null << 'EOF'
[Unit]
Description=Prometheus Node Exporter
After=network.target
 
[Service]
User=node_exporter
ExecStart=/usr/local/bin/node_exporter
Restart=always
 
[Install]
WantedBy=multi-user.target
EOF
 
sudo systemctl enable --now node_exporter

Disk usage alerting is the one monitoring that every VPS needs, full stop. A database server that runs out of disk space silently corrupts data or stops accepting writes. Set up an alert before you need it.

Backups

Automated daily snapshots on every IaaS provider. Enable them the day you provision the server, not after something goes wrong. Keep the last seven daily snapshots and the last four weekly snapshots. Test restoration once — actually restore a snapshot to a new server and verify the application starts correctly. The backup that has never been tested has not been proven to work.

For database backups specifically, snapshots are not enough. Take a logical dump:

#!/bin/bash
# /etc/cron.daily/backup-postgres
 
BACKUP_DIR="/var/backups/postgres"
DB_NAME="myapp"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
 
mkdir -p "$BACKUP_DIR"
pg_dump -U postgres "$DB_NAME" | gzip > "$BACKUP_FILE"
 
# Upload to S3 / DigitalOcean Spaces
aws s3 cp "$BACKUP_FILE" "s3://my-backups/postgres/${DB_NAME}_${TIMESTAMP}.sql.gz"
 
# Keep last 7 local backups
ls -1t "${BACKUP_DIR}"/*.sql.gz | tail -n +8 | xargs rm -f
 
echo "Backup complete: $BACKUP_FILE"

The snapshot restores the entire server. The database dump lets you restore just the data without rebuilding the whole server. Both are necessary.

Containers on IaaS

Docker and Kubernetes on IaaS give you full control over the container runtime. The provider does not care what you run on your VM. You want Docker? Install Docker. You want three containers and a cron job and a Redis instance on the same box? Go ahead.

A typical containerized deployment using Docker Compose:

# docker-compose.yml
version: '3.9'
 
services:
  api:
    image: ghcr.io/company/api:${RELEASE_TAG}
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://myapp:${DB_PASSWORD}@postgres:5432/myapp
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
 
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      - api
 
volumes:
  postgres_data:
  redis_data:

The API port is bound to 127.0.0.1:3000 — only Nginx on the same server can reach it. PostgreSQL and Redis are on the Docker internal network only — not accessible from outside the server. Nginx terminates TLS and proxies to the API. This is the correct network topology for a single-server deployment.

Nginx Configuration for Production

# /etc/nginx/conf.d/myapp.conf
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    server_name example.com www.example.com;
 
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_session_cache   shared:SSL:10m;
 
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 
    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 60s;
        proxy_connect_timeout 5s;
    }
 
    location /health {
        proxy_pass         http://127.0.0.1:3000;
        access_log         off;
    }
}
# Free TLS certificates with certbot
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
 
# Auto-renewal via cron
echo "0 12 * * * root certbot renew --quiet" | sudo tee -a /etc/crontab

IaaS vs PaaS: The Real Decision

The honest comparison is about where you want your operational burden to sit.

On PaaS, the platform manages OS updates, runtime security, TLS certificates, scaling events, and service restarts. You push code and it runs. The tradeoff: the platform makes decisions for you, costs more at scale, and constraints you to what the platform supports.

On IaaS, you own all of that. You also own the flexibility. Any runtime, any configuration, any tool. Running Rust binaries, custom kernel parameters, specific PostgreSQL extensions, a self-hosted Gitea instance alongside your API — IaaS handles it without the platform telling you what is supported.

The crossover point — where IaaS becomes cheaper than PaaS — depends on what you are running. For a simple Node.js API and a database, a $24/month VPS on DigitalOcean handles more traffic than the equivalent Heroku setup that costs $50-100/month. For a complex microservices architecture with auto-scaling needs, managed Kubernetes on a cloud provider may justify the cost over self-managed.

I run CoderOasis on a VPS. The Ghost CMS, the PostgreSQL database, the Nginx reverse proxy, the automated backups — all on a single VPS I manage. The operational overhead is maybe an hour a month. The cost savings over equivalent PaaS hosting are real. And when something goes wrong, I have root access to diagnose it. The What is SaaS article covers the opposite end of that spectrum — when it makes sense to let someone else manage everything and just pay for access.

IaaS is not the default for everyone. It is the right call when you need control, when PaaS costs are prohibitive at your scale, or when your requirements exceed what managed platforms support. When those conditions are true, understanding IaaS properly is what lets you run production infrastructure confidently.

Managed Databases on IaaS

Running your database on the same VPS as your application is fine for small projects. As you scale, separating the database to its own server — or to a managed database service — makes operational sense.

The two approaches for databases on IaaS:

Self-managed database on a dedicated VPS. Your application VMs communicate to a database VM over the private network. You manage PostgreSQL yourself: installation, configuration, backups, WAL archiving, vacuuming, tuning. Full control, lowest cost, highest operational burden.

Managed database service from the IaaS provider. DigitalOcean Managed Databases, AWS RDS, Linode Managed PostgreSQL — the provider runs the database server, handles OS patches, automated backups, read replicas, and failover. You connect with a connection string and run your queries. The operational burden drops to query optimization and schema management.

For most applications on IaaS, the managed database service is the right call unless you have very specific requirements (particular PostgreSQL extensions, custom configurations, extreme cost sensitivity at scale).

# DigitalOcean Managed PostgreSQL — provision via CLI
doctl databases create myapp-db \
    --engine pg \
    --version 16 \
    --region nyc3 \
    --size db-s-1vcpu-1gb \
    --num-nodes 1
 
# Get connection string
doctl databases connection myapp-db --format URI
 
# Trust only your application servers
doctl databases firewalls append myapp-db \
    --rule droplet:your-app-droplet-id

The firewall rule allows only your application Droplet to connect to the database. Port 5432 is not publicly accessible. This is the correct network topology regardless of whether you are using a managed database or self-hosting.

Connection pooling is essential when your application opens many database connections. Each Node.js instance or Python worker maintains a connection pool. If you run 10 application processes each with a pool of 10 connections, you have 100 database connections open. PostgreSQL handles hundreds of connections but with overhead per connection. PgBouncer as a connection pooler sits between your application and PostgreSQL, multiplexing many application connections over fewer actual database connections.

Object Storage for IaaS Deployments

Every application that handles user-uploaded files needs object storage. Storing files on the application server's local filesystem breaks when you scale to multiple servers (each server has different files), fails on container restarts (the filesystem is ephemeral), and creates backup complexity (you need to back up both the database and the filesystem).

Object storage solves this. Files live in a bucket accessible from any server, persist independently of any application instance, and most providers offer built-in CDN delivery.

// Upload to DigitalOcean Spaces (S3-compatible)
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 
const s3 = new S3Client({
    endpoint: 'https://nyc3.digitaloceanspaces.com',
    region:   'nyc3',
    credentials: {
        accessKeyId:     process.env.SPACES_KEY!,
        secretAccessKey: process.env.SPACES_SECRET!,
    },
});
 
async function uploadFile(
    buffer:      Buffer,
    filename:    string,
    contentType: string
): Promise<string> {
    const key = `uploads/${Date.now()}-${filename}`;
 
    await s3.send(new PutObjectCommand({
        Bucket:      process.env.SPACES_BUCKET!,
        Key:         key,
        Body:        buffer,
        ContentType: contentType,
        ACL:         'private',
    }));
 
    return key;
}
 
async function getPresignedUrl(key: string, expiresIn = 3600): Promise<string> {
    return getSignedUrl(s3, new GetObjectCommand({
        Bucket: process.env.SPACES_BUCKET!,
        Key:    key,
    }), { expiresIn });
}

The AWS SDK works with any S3-compatible object storage — DigitalOcean Spaces, Linode Object Storage, Hetzner Object Storage, MinIO (self-hosted). The endpoint URL changes; the API is identical.

Load Balancing and Multiple VMs

Single server deployments have a single point of failure. When the server goes down, the application is down. Scaling to multiple servers behind a load balancer addresses both availability and capacity.

# DigitalOcean Load Balancer — provision via CLI
doctl compute load-balancer create \
    --name myapp-lb \
    --region nyc3 \
    --forwarding-rules "entry_protocol:https,entry_port:443,target_protocol:http,target_port:3000,certificate_id:cert-id" \
    --health-check "protocol:http,port:3000,path:/health/live,check_interval_seconds:10,response_timeout_seconds:5,unhealthy_threshold:3,healthy_threshold:2" \
    --droplet-ids "droplet-id-1,droplet-id-2,droplet-id-3"

The load balancer terminates TLS, forwards HTTP to the application servers, and automatically removes servers that fail health checks from rotation. When you deploy new code, update one server at a time — the load balancer routes around the server being updated.

For stateless applications (which they should be — session state in Redis, not in process memory), any server can handle any request. The load balancer uses round-robin or least-connections distribution to spread load.

The Full IaaS Stack in Practice

After everything above, here is what a production IaaS deployment actually looks like for a medium-scale application:

                        ┌──────────────────┐
                        │   Cloudflare CDN │  (DNS + DDoS protection)
                        └────────┬─────────┘
                                 │
                        ┌────────▼─────────┐
                        │   Load Balancer  │  (DigitalOcean / Nginx VM)
                        │   TLS termination│
                        └────────┬─────────┘
                    ┌────────────┴────────────┐
                    │                         │
           ┌────────▼────────┐       ┌────────▼────────┐
           │  App Server 1   │       │  App Server 2   │
           │  Node.js / PM2  │       │  Node.js / PM2  │
           │  Docker Compose │       │  Docker Compose │
           └────────┬────────┘       └────────┬────────┘
                    │   (Private Network)      │
           ┌────────▼─────────────────────────▼────────┐
           │         Managed PostgreSQL                  │
           │         Primary + Read Replica             │
           └────────────────────────────────────────────┘
                    │
           ┌────────▼────────┐
           │  Managed Redis  │  (Sessions, caching, queues)
           └────────────────-┘

Each component runs in the private network. Nothing except the load balancer is publicly accessible. Application servers handle requests, PostgreSQL stores data, Redis handles sessions and caching. The load balancer provides redundancy and TLS termination. Cloudflare sits in front for DDoS protection and global CDN caching.

This architecture handles millions of requests per day on modest VPS hardware. The operational cost is real — monitoring, deployments, database management — but the infrastructure cost is a fraction of equivalent PaaS or managed Kubernetes.

That is what IaaS actually looks like in practice. Maximum control, maximum responsibility, and when you get it right, the best economics at scale.

CI/CD Pipelines for IaaS Deployments

A proper CI/CD pipeline is the difference between IaaS being a burden and IaaS being genuinely efficient. GitHub Actions is the standard:

# .github/workflows/deploy.yml
name: Deploy
 
on:
  push:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm test
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /app
            export RELEASE_TAG=${{ github.sha }}
            docker compose pull api
            docker compose up -d --no-deps api
            docker compose exec -T api wget -qO- http://localhost:3000/health/ready || exit 1
            docker image prune -f

Every push to main triggers the pipeline: run tests, build and push the Docker image to the container registry, SSH into the production server, pull the new image, replace only the API container (not the database), and verify the health check passes. If the health check fails, the deployment is aborted and the previous container is still running.

--no-deps api in docker compose up only recreates the API service without touching PostgreSQL or Redis. Container rollback is export RELEASE_TAG=<previous-sha> && docker compose up -d --no-deps api. The entire process from git push to live deployment takes under two minutes.

The Long-Term IaaS Investment

The first month on IaaS involves learning: setting up the server, understanding systemd, configuring Nginx, establishing backup routines, setting up monitoring. This is a real time cost.

After that initial investment, the ongoing maintenance is measured in hours per month, not days. apt upgrade monthly, occasional dependency updates, monitoring dashboards checked periodically, backup restoration tests quarterly. The server runs. The application runs. The operational cost is genuinely low once the foundation is solid.

The economics hold up at scale in ways that PaaS does not. A VPS that handles your production load costs the same whether you have 10 users or 10,000 users — the compute is provisioned, not metered by request. PaaS platforms cost more as usage grows. At some point — and that point varies by platform and usage pattern — the VPS economics win decisively.

IaaS is not the answer for every team at every stage. But for teams who have a handle on Linux administration, who have built repeatable deployment automation, and whose scale makes the economics compelling: it is one of the most cost-efficient ways to run production software.