I Turned a $7 Lightsail VM Into a Staging Server
My side projects had a staging gap. Local Docker was useful for checking an application before a release, but it still ran on my laptop. A larger AWS setup with a VPC, load balancer, container platform, and deployment pipeline would answer a different question and create much more infrastructure than I needed. I wanted something in the middle: one server with a stable address, controlled access, repeatable deployment, a health check, and enough recovery behavior to be useful during development. So I tested Amazon Lightsail as a small staging environment instead of treating it as a simplified hosting tutorial.
The Smallest Useful Staging Stack
I deliberately kept the architecture narrow:
Developer laptop
โโโ Terraform โ Lightsail instance, SSH key, static IP, firewall
โโโ deploy.sh โ SSH/SCP โ Docker Compose
โโโ Node.js app
โโโ /health
โโโ /version
The instance ran Ubuntu 24.04 in ca-central-1 on the Micro 1 GB Linux plan with public IPv4. AWS currently lists that bundle at $7 per month, billed hourly up to the monthly maximum. There was no database, load balancer, Kubernetes cluster, or CI/CD system. The point was to learn whether one developer could operate a useful staging VM without quietly building a small platform team around it.
Step 1: Define More Than an Instance
The Terraform resource for the VM was only one part of the environment:
resource "aws_lightsail_instance" "lightsail_instance" {
name = "${var.project_name}-lightsail-instance"
availability_zone = var.availability_zone
blueprint_id = var.blueprint_id
bundle_id = var.bundle_id
key_pair_name = aws_lightsail_key_pair.lightsail_instance_key.name
user_data = file("${path.module}/../scripts/bootstrap.sh")
}
I also needed a custom SSH public key, a static IP, the attachment connecting that address to the instance, and explicit firewall rules.
The static IP was not just extra Terraform. Lightsail's default public IPv4 address can change when an instance is stopped and started. A staging endpoint is much easier to use when its address stays stable, and Lightsail lets the same static address move to a replacement instance later.
The SSH key had a similarly clear boundary. Terraform uploaded only the public key:
resource "aws_lightsail_key_pair" "lightsail_instance_key" {
name = "${var.project_name}-key"
public_key = file(pathexpand(var.ssh_public_key_path))
}
The private key never entered Terraform, the repository, or the article.
Step 2: Replace the Default Firewall Rules
A base Lightsail Linux instance can start with SSH on port 22 and HTTP on port 80 open to every address. That is convenient for a first connection, but it was wider than this experiment required. I replaced those defaults with two rules restricted to my temporary public /32 address:
resource "aws_lightsail_instance_public_ports" "lightsail_instance_firewall" {
instance_name = aws_lightsail_instance.lightsail_instance.name
port_info {
protocol = "tcp"
from_port = 22
to_port = 22
cidrs = [var.developer_cidr]
}
port_info {
protocol = "tcp"
from_port = 80
to_port = 80
cidrs = [var.developer_cidr]
}
}
The Lightsail firewall documentation recommends limiting SSH to the address that needs administrative access. I restricted HTTP as well because this was a private experiment, not a public site. For a real shared staging environment, the source ranges and access model would need another review. A developer's changing home IP is manageable for a short test but awkward for a team.
Step 3: Make Application State Observable
The application was intentionally small. It used Node's built-in HTTP module and exposed two endpoints:
if (request.url === "/health") {
return sendJson(response, 200, { status: "ok" });
}
if (request.url === "/version") {
return sendJson(response, 200, { version });
}
/health answered whether the process was responding. /version answered whether the expected deployment was running. Those endpoints were more useful than a generic home page because each test had a precise success condition.
Docker Compose added the recovery policy and its own health check:
services:
app:
build:
context: .
environment:
APP_VERSION: ${APP_VERSION:-local}
ports:
- "${HOST_PORT:-8080}:3000"
restart: unless-stopped
healthcheck:
test:
- CMD
- node
- -e
- >-
require('node:http').get('http://127.0.0.1:3000/health', response => process.exit(response.statusCode === 200 ? 0 : 1))
.on('error', () => process.exit(1))
interval: 5s
timeout: 3s
retries: 6
The container also ran as a non-root application user. No application secret was required, and the generated .env file on the server had mode 0600.
Step 4: Keep Deployment Separate From Terraform
Terraform created the server. A separate deployment script copied the application files over SSH and ran Compose:
./scripts/deploy.sh "$host" v2 "<SSH_PRIVATE_KEY_PATH>"
On the server, the important command was:
sudo docker compose up --build --detach --remove-orphans
That separation mattered. Changing v1 to v2 did not require replacing the instance or changing Terraform state. The verification script then checked both endpoints until they agreed with the requested release:
./scripts/verify.sh "$host" v2 120
The observed result was:
PASS health=ok version=v2 recovery_seconds=0
The complete v1 to v2 deployment took 6 seconds, and the first verification request already returned the new version.
Comments
No comments yet. Start the discussion.