v1.10.90-0e025b8
Skip to main content
TutorialAWSCode

Serverless Proxy Management on AWS Lambda

11 min read

By Hex Proxies Engineering Team

Serverless Proxy Management on AWS Lambda

For a small team running periodic scrapers, a serverless control plane beats a persistent server. There's nothing to patch, nothing to monitor, and the bill is a few cents a month. This guide builds a proxy session management API on AWS Lambda, API Gateway HTTP API, and DynamoDB — the whole thing fits in one SAM template and two files of code.

The design: clients POST to /sessions to allocate a sticky proxy session, GET to retrieve the gateway URL, DELETE to release it. DynamoDB stores session metadata with TTL-based cleanup. Secrets Manager holds the upstream proxy credentials so they never appear in CloudWatch logs or environment variables.

SAM template

ARM64 Lambda (Graviton) is 20% cheaper than x86 at equal performance for Node.js workloads. Use it unless you have native dependencies that only ship x86 binaries.

# template.yaml (AWS SAM)
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless proxy management API

Globals:
  Function:
    Runtime: nodejs22.x
    Architectures: [arm64]
    MemorySize: 512
    Timeout: 29
    Environment:
      Variables:
        SESSION_TABLE: !Ref ProxySessionTable
        PROXY_SECRET_ARN: !Ref ProxyCredentials
    Layers:
      - !Ref SharedLayer

Resources:
  SharedLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: proxy-shared
      ContentUri: layers/shared/
      CompatibleRuntimes: [nodejs22.x]
      RetentionPolicy: Delete

  ProxySessionTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - { AttributeName: sessionId, AttributeType: S }
      KeySchema:
        - { AttributeName: sessionId, KeyType: HASH }
      TimeToLiveSpecification:
        AttributeName: expiresAt
        Enabled: true

  ProxyCredentials:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: /scraper/hex-proxy-credentials
      Description: Hex Proxies gateway credentials

  SessionApi:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/session/
      Handler: index.handler
      Policies:
        - DynamoDBCrudPolicy: { TableName: !Ref ProxySessionTable }
        - Statement:
            - Effect: Allow
              Action: secretsmanager:GetSecretValue
              Resource: !Ref ProxyCredentials
      Events:
        CreateSession:
          Type: HttpApi
          Properties: { Method: POST, Path: /sessions }
        GetSession:
          Type: HttpApi
          Properties: { Method: GET, Path: /sessions/{id} }
        DeleteSession:
          Type: HttpApi
          Properties: { Method: DELETE, Path: /sessions/{id} }

Outputs:
  ApiUrl:
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"

A few decisions worth calling out. DynamoDB TTL handles session expiration — we write expiresAt as a Unix timestamp and AWS sweeps expired rows within 48 hours. For tight expiration guarantees, also check expiresAt on read. HttpApi instead of RestApi — cheaper, faster, and has everything this workload needs. PAY_PER_REQUEST billing — provisioned capacity is only worth it above ~1M reads/day.

Shared Lambda Layer

The proxy-client module lives in a Lambda Layer so every function sees it at /opt/nodejs/proxy-client.mjs. This keeps credential-fetching logic in one place and lets us cache the Secrets Manager response across warm invocations.

// layers/shared/nodejs/proxy-client.mjs
import { SecretsManagerClient, GetSecretValueCommand }
  from "@aws-sdk/client-secrets-manager";

const sm = new SecretsManagerClient({});
let cachedCreds = null;
let cachedUntil = 0;

export async function getProxyCredentials() {
  const now = Date.now();
  if (cachedCreds && now < cachedUntil) return cachedCreds;

  const res = await sm.send(new GetSecretValueCommand({
    SecretId: process.env.PROXY_SECRET_ARN,
  }));
  if (!res.SecretString) throw new Error("empty proxy secret");
  cachedCreds = JSON.parse(res.SecretString);
  // Cache for 10 minutes across warm invocations.
  cachedUntil = now + 10 * 60 * 1000;
  return cachedCreds;
}

export function buildGatewayUrl(creds, sessionId) {
  // Hex supports sticky sessions via username suffix.
  const user = `${creds.username}-session-${sessionId}`;
  return `http://${user}:${creds.password}@${creds.host}:${creds.port}`;
}

The in-memory cache is important for cost. Without it, every Lambda invocation calls Secrets Manager ($0.05 per 10,000 calls plus a few ms of latency). With 10-minute caching, a warm Lambda makes one Secrets Manager call per 10 minutes of activity.

Session function

// functions/session/index.mjs
import { randomUUID } from "node:crypto";
import {
  DynamoDBClient,
} from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  PutCommand, GetCommand, DeleteCommand,
} from "@aws-sdk/lib-dynamodb";
import { getProxyCredentials, buildGatewayUrl } from "/opt/nodejs/proxy-client.mjs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.SESSION_TABLE;
const TTL_SECONDS = 30 * 60; // 30-minute sticky window

function json(statusCode, body) {
  return {
    statusCode,
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  };
}

export async function handler(event) {
  const method = event.requestContext.http.method;
  const path = event.rawPath;

  try {
    if (method === "POST" && path === "/sessions") {
      return await createSession(event);
    }
    if (method === "GET" && path.startsWith("/sessions/")) {
      return await getSession(path.split("/")[2]);
    }
    if (method === "DELETE" && path.startsWith("/sessions/")) {
      return await deleteSession(path.split("/")[2]);
    }
    return json(404, { error: "not found" });
  } catch (err) {
    console.error("handler error", err);
    return json(500, { error: "internal" });
  }
}

async function createSession(event) {
  const body = event.body ? JSON.parse(event.body) : {};
  const sessionId = body.sessionId ?? randomUUID();
  const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS;

  const creds = await getProxyCredentials();
  const gatewayUrl = buildGatewayUrl(creds, sessionId);

  await ddb.send(new PutCommand({
    TableName: TABLE,
    Item: {
      sessionId,
      expiresAt,
      label: body.label ?? null,
      createdAt: new Date().toISOString(),
    },
  }));

  return json(201, {
    sessionId,
    expiresAt,
    proxyUrl: gatewayUrl,
  });
}

async function getSession(sessionId) {
  const res = await ddb.send(new GetCommand({
    TableName: TABLE,
    Key: { sessionId },
  }));
  if (!res.Item) return json(404, { error: "not found" });

  const creds = await getProxyCredentials();
  return json(200, {
    ...res.Item,
    proxyUrl: buildGatewayUrl(creds, sessionId),
  });
}

async function deleteSession(sessionId) {
  await ddb.send(new DeleteCommand({
    TableName: TABLE,
    Key: { sessionId },
  }));
  return json(204, {});
}

Two details. First, the function uses @aws-sdk/lib-dynamodb's DocumentClient, which handles marshalling between JS objects and DynamoDB attribute types automatically. Second, the gateway URL is generated on the fly from session ID + cached credentials — it is never persisted, so a leak of the DynamoDB table does not leak credentials.

IAM scoping

The Lambda execution role only has secretsmanager:GetSecretValue on the specific secret ARN, and DynamoDBCrudPolicy on the specific table. No wildcards. If this Lambda is compromised, the blast radius is limited to (a) reading the proxy credentials and (b) CRUD on the session table. Nothing else.

Cost math

At 100k sessions per month: Lambda is ~$0.20, DynamoDB is ~$0.25, Secrets Manager is ~$0.40, HttpApi is ~$0.10. Total: about $1. Compare to a t4g.nano running the same thing 24/7 at $3/month. Serverless wins for this workload below 1-2M requests per month; above that, a small container starts to look attractive.

What this doesn't handle

There's no rotation logic — clients are expected to create a new session when they want a new identity. That's intentional: keeping the control plane stateless makes everything easier to reason about. For rotation policy, put it in the scraper or in the Kubernetes sidecar.

Hex Proxies sticky sessions plug into this pattern directly — set creds.host to gate.hexproxies.com, creds.port to 7777, and the username suffix handles the rest. See pricing and the rotation primer.