Managing an Amazon AWS EC2 Instance with Node.js

This guide provides a complete walkthrough for starting, stopping, rebooting, describing, and creating EC2 instances using Node.js and the AWS SDK for JavaScript v3. The examples are designed for Node 18+ and assume credentials configured in the AWS profile or environment variables.

Prerequisites

  • An AWS account with permissions on EC2 (preferably via a least-privilege role).
  • Node.js 18 or later installed.
  • Credentials configured with aws configure or environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION.

Example of a least-privilege IAM policy for basic management

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeInstanceStatus",
        "ec2:StartInstances",
        "ec2:StopInstances",
        "ec2:RebootInstances"
      ], "Resource": "*" }
  ]
}

If you also need to create/terminate instances, add: ec2:RunInstances, ec2:TerminateInstances, ec2:CreateTags, and permissions related to key pairs and security groups.

Installing packages

npm init -y
npm i @aws-sdk/client-ec2 @aws-sdk/credential-providers

Configuring the EC2 client

// ec2-client.js
import { EC2Client } from "@aws-sdk/client-ec2";
import { fromEnv } from "@aws-sdk/credential-providers";

export const ec2 = new EC2Client({
  region: process.env.AWS_REGION || "eu-central-1",
  credentials: process.env.AWS_ACCESS_KEY_ID
  ? fromEnv()
  : undefined // will use local profile/role if available
});

Common operations on an existing instance

Suppose we have the instance ID, e.g. i-0123456789abcdef0. Create reusable functions:

Describe state and details

// describe.js
import { DescribeInstancesCommand } from "@aws-sdk/client-ec2";
import { ec2 } from "./ec2-client.js";

export async function describeInstance(instanceId) {
  const cmd = new DescribeInstancesCommand({ InstanceIds: [instanceId] });
  const res = await ec2.send(cmd);
  const reservation = res.Reservations?.[0];
  const inst = reservation?.Instances?.[0];
  if (!inst) throw new Error("Instance not found");
  return {
    id: inst.InstanceId,
    state: inst.State?.Name,
    type: inst.InstanceType,
    az: inst.Placement?.AvailabilityZone,
    publicIp: inst.PublicIpAddress,
    privateIp: inst.PrivateIpAddress,
    launchTime: inst.LaunchTime
  };
}

Start, stop, reboot

// lifecycle.js
import {
  StartInstancesCommand,
  StopInstancesCommand,
  RebootInstancesCommand
} from "@aws-sdk/client-ec2";
import { ec2 } from "./ec2-client.js";

export async function startInstance(instanceId) {
  await ec2.send(new StartInstancesCommand({ InstanceIds: [instanceId] }));
}

export async function stopInstance(instanceId, hibernate = false) {
  await ec2.send(new StopInstancesCommand({
    InstanceIds: [instanceId],
    Hibernate: hibernate
  }));
}

export async function rebootInstance(instanceId) {
  await ec2.send(new RebootInstancesCommand({ InstanceIds: [instanceId] }));
}

Waiting for state transitions (waiters)

Waiters can reliably wait until the instance is running or stopped before proceeding.

// waiters.js
import {
  waitUntilInstanceRunning,
  waitUntilInstanceStopped
} from "@aws-sdk/client-ec2";
import { ec2 } from "./ec2-client.js";

export async function waitRunning(instanceId, maxWaitSeconds = 300) {
  const out = await waitUntilInstanceRunning(
    { client: ec2, maxWaitTime: maxWaitSeconds },
    { InstanceIds: [instanceId] }
  );
  if (out.state !== "SUCCESS") throw new Error("Timeout while waiting for running");
}

export async function waitStopped(instanceId, maxWaitSeconds = 300) {
  const out = await waitUntilInstanceStopped(
    { client: ec2, maxWaitTime: maxWaitSeconds },
    { InstanceIds: [instanceId] }
  );
  if (out.state !== "SUCCESS") throw new Error("Timeout while waiting for stopped");
}

Creating a new instance

To create an instance you need: AMI, instance type, key pair (for SSH), security group, and subnet. Here’s a minimal example with tags and user data to install Nginx on Amazon Linux 2023.

// create-instance.js
import {
  RunInstancesCommand,
  CreateTagsCommand,
  DescribeImagesCommand
} from "@aws-sdk/client-ec2";
import { ec2 } from "./ec2-client.js";

// Optional: fetch the latest Amazon Linux 2023 AMI (x86_64) for the region
async function getLatestAL2023Ami() {
  const res = await ec2.send(new DescribeImagesCommand({
    Owners: ["amazon"],
    Filters: [
      { Name: "name", Values: ["al2023-ami-*-x86_64"] },
      { Name: "state", Values: ["available"] },
      { Name: "architecture", Values: ["x86_64"] },
      { Name: "root-device-type", Values: ["ebs"] }
    ]
  }));
  const images = res.Images ?? [];
  images.sort((a, b) => new Date(b.CreationDate) - new Date(a.CreationDate));
  if (!images[0]?.ImageId) throw new Error("AMI not found");
  return images[0].ImageId;
}

export async function createInstance({
  amiId,
  instanceType = "t3.micro",
  keyName,
  securityGroupIds,
  subnetId,
  tags = { Project: "demo-node-ec2" }
}) {
  const imageId = amiId || await getLatestAL2023Ami();
  const userData = Buffer.from(`#!/bin/bash
    dnf -y update
    dnf -y install nginx
    systemctl enable nginx
    systemctl start nginx
  `).toString("base64");

  const run = new RunInstancesCommand({
    ImageId: imageId,
    InstanceType: instanceType,
    KeyName: keyName,
    SecurityGroupIds: securityGroupIds,
    SubnetId: subnetId,
    MinCount: 1,
    MaxCount: 1,
    TagSpecifications: [
      {
        ResourceType: "instance",
        Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value }))
      }
    ],
    UserData: userData
  });

  const out = await ec2.send(run);
  const instanceId = out.Instances?.[0]?.InstanceId;
  if (!instanceId) throw new Error("Instance creation failed");

  // (Optional) add tags to the root volume
  try {
    const volumeId = out.Instances?.[0]?.BlockDeviceMappings?.[0]?.Ebs?.VolumeId;
    if (volumeId) {
      await ec2.send(new CreateTagsCommand({
        Resources: [volumeId],
        Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value }))
      }));
    }
  } catch {}

  return instanceId;
}

Secure management of credentials and roles

  • Prefer using IAM Roles (e.g., on an EC2/CodeBuild runner) instead of static keys.
  • For local environments, use profiles in ~/.aws/credentials and AWS_PROFILE.
  • Limit permissions to the minimum necessary: separate the “read/describe” policy from the “lifecycle” one.

Operational best practices

  1. Idempotency: protect repeated commands (e.g., check the state before calling StartInstances/StopInstances).
  2. Retry and backoff: AWS APIs may respond with throttling; use exponential backoff retries if integrating more complex orchestrations.
  3. Waiters: rely on waiters for consistent states before executing next steps (IP allocation, tagging, health check).
  4. Tagging: apply consistent tags for cost allocation and search (Project, Env, Owner).
  5. Security: protect private key pairs; restrict security groups (SSH only from your IP, HTTP/HTTPS as needed).
  6. Shutdown: automate stopping unused instances to contain costs (e.g., external cron or Lambda Scheduler).

Quick diagnostics

// utils-status.js
import { DescribeInstanceStatusCommand } from "@aws-sdk/client-ec2";
import { ec2 } from "./ec2-client.js";

export async function instanceChecks(instanceId) {
  const res = await ec2.send(new DescribeInstanceStatusCommand({
    InstanceIds: [instanceId],
    IncludeAllInstances: true
  }));
  return res.InstanceStatuses?.[0] ?? null;
}

Conclusion

With the AWS SDK v3 and a few well-structured functions you can orchestrate the entire lifecycle of an EC2 instance directly from Node.js: from creation with user data to state checks, up to scheduled shutdown. Adapt the examples to your IAM rules, security groups, and your networking and observability needs.

Back to top