In this first post of my new Local Dev Craft series, we will be setting up a local Kubernetes cluster using
kind,KEDA, andLocalStack AWSin order to build a development/debugging environment that is similar to production. But first, let’s get familiar with the tools we are going to use here:
- Kubernetes: An open-source platform designed to automate deploying, scaling, and operating application containers.
- kind (Kubernetes in Docker): A tool for running local Kubernetes clusters using Docker container “nodes”.
- KEDA (Kubernetes Event-Driven Autoscaling): A set of components that extends Kubernetes to provide event-driven autoscaling for every container.
- LocalStack AWS: A fully functional local AWS cloud stack for testing and mocking AWS services locally.
Now that we know what we’re dealing with, let’s move on and create our local development environment that mimics our cloud setup. This setup will be a set of stateless jobs that will be spawned by KEDA events, exit after execution, and scale to zero.
Prerequisite: Docker Installation
Before we go on and install kind, make sure that Docker is installed on your machine as kind requires it to create clusters. If Docker isn’t installed yet, follow the Docker installation instructions.
Setting Up kind
Now kind, it will manage our cluster and be our control plane for our local env. Install it using Homebrew like this:
brew install kind
If you’re not on macOS or prefer a different method of installation, refer to kind’s GitHub page for alternatives.
Once kind is installed, create a cluster with three nodes to have a more realistic environment like this:
kind create cluster --config kind-config.yaml
Where kind-config.yaml contains:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
And there you have it! You’ve spun up a local Kubernetes cluster with three nodes. To interact with your new cluster, make sure you have kubectl installed. If you don’t, follow these instructions.
Setting Up KEDA
Next, we move to KEDA. It will let our Kubernetes pods scale based on event metrics as we planned. To install KEDA, we’ll use a Helm chart. Make sure you have Helm installed first, and run the following:
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
kubectl create namespace keda
helm install keda kedacore/keda --namespace keda
Setting Up LocalStack AWS
Now, it’s LocalStack’s turn. With LocalStack, we’ll simulate AWS services locally. Start LocalStack with Docker using the following command:
docker run -d \
-e SERVICES=s3,sqs \
-p 4566:4566 \
-p 4510:4510 \
--name localstack \
localstack/localstack:4.6
Note the pinned version. The latest image now refuses to start without a license auth token, so pin a community version and save yourself the surprise.
ANd now we have LocalStack running with s3 and sqs services, we need them both for an s3 file trigger. Feel free to append other AWS services to the SERVICES environment variable as needed.
One more thing before we can talk to it. The aws CLI refuses to send anything without credentials and a region, even to a mock. LocalStack doesn’t validate them, so anything goes:
export AWS_ACCESS_KEY_ID=mock-access-key
export AWS_SECRET_ACCESS_KEY=mock-secret-key
export AWS_DEFAULT_REGION=us-east-1
To complete the setup, we need to run three commands, in this exact order.
1. Create the SQS queue
This is the queue KEDA will watch for its scaling metrics:
aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name my-s3-event-queue
2. Create the S3 bucket
Replace your-bucket with whatever name you like:
aws --endpoint-url=http://localhost:4566 s3api create-bucket --bucket your-bucket
3. Connect the bucket to the queue
This tells our local S3 to send a message to the queue every time a file is uploaded (s3:ObjectCreated:*):
aws --endpoint-url=http://localhost:4566 s3api put-bucket-notification-configuration \
--bucket your-bucket \
--notification-configuration '{
"QueueConfigurations": [
{
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:my-s3-event-queue",
"Events": ["s3:ObjectCreated:*"]
}
]
}'
And that’s the LocalStack side done. From now on, every file dropped into your-bucket generates a message in our SQS queue, and that message is what will trigger KEDA to spin up the application.
The Worker Application
Now we need an actual application to consume those events. Nothing fancy: a small TypeScript worker that long-polls the SQS queue, prints the S3 event details, and deletes the message. Once the queue is empty, KEDA can scale our pods back down to zero.
Start a new project and install the AWS SDK:
npm install @aws-sdk/client-sqs
npm install --save-dev @types/node typescript
Then create index.ts with the following:
import {
DeleteMessageCommand,
Message,
ReceiveMessageCommand,
SQSClient,
} from "@aws-sdk/client-sqs";
const sqs = new SQSClient({
region: process.env.AWS_REGION ?? "us-east-1",
endpoint: process.env.SQS_ENDPOINT ?? "http://localhost:4566",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "mock-access-key",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "mock-secret-key",
},
});
const queueUrl =
process.env.SQS_QUEUE_URL ??
"http://localhost:4566/000000000000/my-s3-event-queue";
async function processMessage(message: Message): Promise<void> {
if (!message.Body || !message.ReceiptHandle) {
throw new Error("Invalid SQS message");
}
const event = JSON.parse(message.Body);
if (Array.isArray(event.Records)) {
for (const record of event.Records) {
const bucket = record.s3?.bucket?.name;
const key = record.s3?.object?.key;
if (!bucket || !key) {
console.warn("Ignoring invalid S3 record:", record);
continue;
}
const fileKey = decodeURIComponent(key.replace(/\+/g, " "));
console.log(`S3 event: ${bucket}/${fileKey}`);
// TODO: actual file processing
}
} else {
// s3:TestEvent and friends -- still delete them, or they get redelivered forever
console.warn("Ignoring non-S3 message:", event);
}
await sqs.send(
new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: message.ReceiptHandle,
}),
);
console.log("Message processed");
}
async function worker(): Promise<void> {
console.log("S3 worker started");
while (true) {
try {
const { Messages = [] } = await sqs.send(
new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 1,
WaitTimeSeconds: 20,
}),
);
for (const message of Messages) {
try {
await processMessage(message);
} catch (error) {
console.error("Failed to process message:", error);
}
}
} catch (error) {
console.error("SQS polling failed:", error);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
worker().catch((error) => {
console.error("Worker stopped:", error);
process.exit(1);
});
The defaults point to localhost:4566 so you can also run the worker directly on your machine with ts-node index.ts for a quick sanity check. Inside the cluster, we’ll override them with env variables.
We also need a tsconfig.json. Don’t rely on tsc --init here. Its output changes between TypeScript versions and the newer ones generate a config that will not to compile this file.
{
"compilerOptions": {
"target": "ES2020",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["index.ts"]
}
Packaging the Worker for kind
To run the worker in our cluster, we need to build it into a Docker image. Create a Dockerfile in the project root, a multi-stage build that compiles the TypeScript and produces a light runtime image:
# --- Stage 1: Build the TypeScript code ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npx tsc
# --- Stage 2: Clean runtime image ---
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --only=production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
Build it:
docker build -t s3-worker:v1 .
Now for kind: since its cluster nodes are Docker containers themselves, hey don’t see the images in your local Docker daemon. You have to load the image into the cluster explicitly:
kind load docker-image s3-worker:v1 --name kind
(Change --name kind if you named your cluster differently.)
Wiring It All Together
With all the pieces in place, let’s deploy our worker to the local Kubernetes cluster and see it in action. Create an app.yaml file with the following content:
apiVersion: v1
kind: Secret
metadata:
name: localstack-dummy-secrets
namespace: default
stringData:
AWS_ACCESS_KEY_ID: mock-access-key
AWS_SECRET_ACCESS_KEY: mock-secret-key
---
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: keda-aws-credentials
namespace: default
spec:
secretTargetRef:
- parameter: awsAccessKeyID
name: localstack-dummy-secrets
key: AWS_ACCESS_KEY_ID
- parameter: awsSecretAccessKey
name: localstack-dummy-secrets
key: AWS_SECRET_ACCESS_KEY
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: example-app
spec:
replicas: 1
selector:
matchLabels:
app: example-app
template:
metadata:
labels:
app: example-app
spec:
containers:
- name: example-app
image: s3-worker:v1 # our image, loaded into kind
imagePullPolicy: IfNotPresent # don't try to pull it from a public registry
env:
- name: SQS_ENDPOINT
value: http://host.docker.internal:4566
- name: SQS_QUEUE_URL
value: http://host.docker.internal:4566/000000000000/my-s3-event-queue
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: example-app-scaler
spec:
scaleTargetRef:
name: example-app
minReplicaCount: 0
maxReplicaCount: 5
cooldownPeriod: 30
triggers:
- type: aws-sqs-queue
metadata:
queueURL: http://host.docker.internal:4566/000000000000/my-s3-event-queue
queueLength: "1" # Scales up immediately when a file drops in
awsRegion: us-east-1
awsEndpoint: http://host.docker.internal:4566
authenticationRef:
name: keda-aws-credentials
Two things to keep in mind here: the imagePullPolicy: IfNotPresent forces Kubernetes to use the image we loaded into kind instead of trying to pull it from the internet, and the endpoints point to host.docker.internal because from inside the cluster nodes, localhost is not your machine. It’s the node container itself.
If you’re on Linux, one more thing: host.docker.internal is a Docker Desktop thing; on a native Linux Docker it doesn’t resolve. So we need to add it to CoreDNS so the pods can resolve it. First find the gateway IP of the kind network (take the IPv4 one, e.g. 172.19.0.1):
Anyway, we will move away from
LocalStackin the next part of this series, so don’t sweat it too much.
docker network inspect kind -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}'
Then kubectl edit configmap coredns -n kube-system and add a hosts block to the Corefile, right above the kubernetes plugin:
hosts {
172.19.0.1 host.docker.internal
fallthrough
}
Restart CoreDNS with kubectl rollout restart deployment coredns -n kube-system and now host.docker.internal resolves cluster-wide — for our worker and for the KEDA operator — and the manifest stays exactly as written.
Apply this manifest to your cluster:
kubectl apply -f app.yaml
And now you have a local Kubernetes setup with KEDA and LocalStack AWS, ready to simulate a production-like environment on your machine.
Let’s Test It
Since minReplicaCount is 0, right after applying the manifest, you should see no pods at all; that’s KEDA doing its job on an empty queue. Now upload a file to the bucket:
aws --endpoint-url=http://localhost:4566 s3api put-object --bucket your-bucket --key dir-1/my_images.tar.bz2 --body my_images.tar.bz2
S3 sends a message to the queue, KEDA sees it and spins up a pod. Watch the worker logs:
kubectl logs -l app=example-app -f
You should see something like:
S3 worker started
S3 event: your-bucket/dir-1/my_images.tar.bz2
Message processed
You might also spot an Ignoring non-S3 message warning in there once. That’s the s3:TestEvent S3 sends when the notification configuration is created. The worker just deletes it and moves on.
The worker deleted the message, so the queue is empty again. After the cooldownPeriod we set in the ScaledObject (30 seconds) has passes, KEDA scales the deployment back down to zero. Run kubectl get pods and watch your pod disappear. That’s the full loop, from a file upload to scale-to-zero, all on your machine.
Conclusion
This setup is just the first step towards creating a local development environment. It’s a playground to explore, develop, and test applications in a controlled setting before they go out into the actual cloud.
Stay tuned as we continue to refine our local setup in the upcoming posts, where we will expand on the architecture that we are building here. Until then, happy coding!
To be continued… with more advanced setup and features for your local dev environment.