AdvikPrep
Tutorial17 min read

Why Your Auto-Scaling App Logs Users Out (And How Redis Fixes It)

SS

Shiv Srivastava

Product Architect · AdvikPrep

Diagram: a user's requests pass through an AWS Application Load Balancer to three EC2 servers in an Auto Scaling Group, which all save sessions in one shared Redis (ElastiCache)
Diagram: a user's requests pass through an AWS Application Load Balancer to three EC2 servers in an Auto Scaling Group, which all save sessions in one shared Redis (ElastiCache)

Why Your Auto-Scaling App Logs Users Out (And How Redis Fixes It)

A hands-on demo with Node.js, Docker, AWS Load Balancer, Auto Scaling and Redis.

The Big Picture

Imagine a shop with one counter and one helper. The helper remembers every customer by name. Business grows, so you hire two more helpers. Now a customer talks to helper A, comes back, and gets helper B. Helper B has never seen this customer. "Who are you? Let's start again."

This is exactly what happens to web apps when they scale out.

  • A session is the app's short memory of one user (logged in, items in cart, page visits).
  • If each server keeps sessions in its own memory, the memory is not shared.
  • When the load balancer sends the user to a different server, the session is gone. The user looks logged out.

In this tutorial we will:

  1. Build a small Node.js app that counts your page visits.
  2. Put it in a Docker container and run it on several AWS servers behind a load balancer.
  3. See the bug live: the counter keeps resetting to 1.
  4. Fix it in two ways: sticky sessions (quick fix) and Redis (the real fix).

What we will build

Here is the whole flow in simple words:

  1. The user opens the ALB (Application Load Balancer) URL.
  2. The ALB picks one of the EC2 servers for each request.
  3. Each EC2 server runs our app inside a Docker container.
  4. The Auto Scaling Group (ASG) adds or removes servers when traffic changes.
  5. In the fixed version, all servers save sessions in one shared Redis.

One door many servers, every request can land on a different one

A user's request reaches the load balancer, which hands it to any one server in the group. In the fixed version, all servers also save sessions in one shared Redis.

Words you will hear in this article

  • ALB: a traffic police officer. It receives all user requests and sends each one to a server.
  • EC2: a virtual computer in AWS. Our app runs here.
  • ASG (Auto Scaling Group): a manager that adds more servers when busy and removes them when quiet.
  • Docker image: our app packed in a box, so it runs the same everywhere.
  • Redis (ElastiCache): a very fast shared notebook that every server can read and write.
  • CDK: we write our AWS setup as TypeScript code instead of clicking in the console.

Step 1: Build the Node.js App

We will make a tiny app that counts how many times you visited the page.

bash
mkdir session-demo && cd session-demo
npm init -y
npm install express express-session

The code below uses import, so open package.json and add this line:

json
"type": "module"

Now create app.js:

javascript
import os from "os";
import express from "express";
import session from "express-session";

const app = express();

// Sessions are stored in the server's own memory.
// This is what causes the bug when we have many servers.
app.use(session({
  secret: "my-super-secret-key",
  resave: false,
  saveUninitialized: true,
  cookie: { maxAge: 60000 }, // session lives for 1 minute
}));

app.get("/", (req, res) => {
  // Count how many times this user opened the page
  req.session.views = (req.session.views || 0) + 1;

  const host = os.hostname(); // unique name of the server/container
  console.log(`[${new Date().toISOString()}] handled by ${host}, views=${req.session.views}`);

  res.set("X-Server", host);
  res.send(`Visited ${req.session.views} times. (Server: ${host})`);
});

app.listen(3000, () => console.log("App running on port 3000"));

We show the server name (os.hostname()) on purpose. Later it will help us see which server answered each request.


Step 2: Pack the App in Docker

Docker puts our app and everything it needs in one box, so it runs the same on your laptop and on AWS.

Create a file named Dockerfile:

dockerfile
FROM node:24.12.0-alpine

# Update the OS packages to fix known security issues
RUN apk update && apk upgrade --no-cache

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]

Build and test it on your laptop:

bash
docker build -t your-dockerhub-username/my-session-app:v1 .
docker run -p 3000:3000 your-dockerhub-username/my-session-app:v1

Open http://localhost:3000 and refresh a few times. The counter goes up nicely. With one server, everything works. Remember this, because it is the "before" picture.

Apple Silicon (M1/M2/M3) users: AWS t3.micro is an x86 machine. Build for it with: docker buildx build --platform linux/amd64 -t your-dockerhub-username/my-session-app:v1 --push .


Step 3: Push the Image to Docker Hub

AWS servers need to download our image from somewhere. We will use Docker Hub.

  1. Create a free account at hub.docker.com and note your username.
  2. Click Repositories → Create Repository, name it my-session-app, and choose Public. A public repo is the easiest because the servers can pull it without a password.
  3. Log in from your terminal (a browser window may open, or use a Personal Access Token instead of your password):
bash
docker login
  1. Push the image:
bash
docker push your-dockerhub-username/my-session-app:v1

If you built the image without your username in the name, tag it first:

bash
docker tag my-session-app:v1 your-dockerhub-username/my-session-app:v1

Step 4: Create the AWS Setup with CDK

Before you start: set up your AWS access

CDK talks to AWS on your behalf, so it needs an identity with permission to create things. Let's set that up first, so every learner starts from the same working point.

There are two different identities in this tutorial. Do not mix them up:

  • You (an IAM user): the person running cdk deploy from your laptop. You create this one now, by hand.
  • The server role (an IAM role): the InstanceSSMRole in our stack code. CDK creates it for you, so you do not create it by hand. It lets the EC2 servers use SSM.

What you need installed

bash
node --version              # Node.js 18 or newer
aws --version               # AWS CLI v2
npm install -g aws-cdk      # the CDK command line
cdk --version

1. Use a safe AWS account

Use a personal or sandbox AWS account, never a company production account. Never use the root user for daily work. Sign in as root only once to create your own user, and turn on MFA for root (Security credentials → Assign MFA device).

2. Create an IAM user for CDK

  1. Open the AWS console and go to IAM → Users → Create user.
  2. User name: cdk-learner. You do not need console access for this user, so leave that box unticked.
  3. On the permissions page choose Attach policies directly, search for AdministratorAccess, and tick it.
  4. Click Create user.

Why AdministratorAccess? The first CDK step (cdk bootstrap) creates roles, storage buckets and more, and the stack itself creates a VPC lookup, security groups, a load balancer, an Auto Scaling Group, an IAM role and Redis. A custom policy for all of that is long and easy to get wrong while learning. It is fine for a sandbox account. For real projects, an admin runs cdk bootstrap once, and day-to-day users only get permission to assume the CDK roles that bootstrap creates (their names start with cdk-).

3. Create an access key for the command line

  1. Open the new user, then Security credentials → Create access key.
  2. Choose Command Line Interface (CLI), confirm, and click Create access key.
  3. Copy the Access key ID and Secret access key, or download the .csv file. The secret is shown only once.

Keep it safe: never paste these keys into code, a README or GitHub. When you finish the demo, delete the access key (or the whole user).

Prefer not to use long-lived keys? AWS IAM Identity Center (SSO) is the recommended way for teams: run aws configure sso and follow the prompts. The rest of this article works the same, you just use the SSO profile name.

4. Save the keys as a named profile

bash
aws configure --profile cdk-learner

It asks four questions:

  • AWS Access Key ID: paste the key from step 3
  • AWS Secret Access Key: paste the secret
  • Default region name: a region close to you, for example us-east-1
  • Default output format: json

This cdk-learner name is what we pass to --profile in the commands below.

5. Check that it works

bash
aws sts get-caller-identity --profile cdk-learner

You should see JSON with your 12-digit Account number and an Arn ending in user/cdk-learner. If you see an error instead, re-check the keys. If it works, you are ready.

6. Bootstrap CDK once per account and region

Bootstrap creates a few helper resources (a storage bucket and roles) that CDK needs before the first deploy:

bash
cdk bootstrap aws://<account-id>/<region> --profile cdk-learner

Use the account number from step 5 and the same region as step 4. You only do this once for each account and region pair.

What we will build in this step

Here is what we will create:

  • Two security groups (firewalls): one for the ALB (open to the internet on port 80), one for the servers (only the ALB can reach port 3000).
  • An Application Load Balancer that spreads the traffic.
  • A Launch Template: the recipe for a server (Amazon Linux, installs Docker, runs our image).
  • An Auto Scaling Group that keeps 1 to 4 servers running and adds more when traffic grows.

Create the project:

bash
mkdir my-session-app-cdk && cd my-session-app-cdk
cdk init app --language typescript

The stack looks up the default VPC, so it needs to know your account and region. In bin/*.ts set:

typescript
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

Now the stack file (lib/*-stack.ts):

typescript
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as autoscaling from "aws-cdk-lib/aws-autoscaling";
import * as iam from "aws-cdk-lib/aws-iam";

export class MySessionAppCdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Use the default VPC (the network that already exists in your account)
    const vpc = ec2.Vpc.fromLookup(this, "DefaultVpc", { isDefault: true });

    // 1. Security groups (firewalls)
    const albSg = new ec2.SecurityGroup(this, "AlbSg", {
      vpc,
      description: "ALB Security Group",
      allowAllOutbound: true,
    });
    albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), "Allow HTTP from anywhere");

    const webSg = new ec2.SecurityGroup(this, "WebSg", {
      vpc,
      description: "Web EC2 Security Group",
      allowAllOutbound: true,
    });
    // Only the load balancer may talk to our servers on port 3000
    webSg.addIngressRule(albSg, ec2.Port.tcp(3000), "Allow port 3000 from ALB");

    // 2. IAM role, so we can open a shell on servers with SSM (no SSH keys needed)
    const ssmRole = new iam.Role(this, "InstanceSSMRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
    });
    ssmRole.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore")
    );

    // 3. Load balancer and target group
    const alb = new elbv2.ApplicationLoadBalancer(this, "WebAlb", {
      vpc,
      internetFacing: true,
      securityGroup: albSg,
    });

    const listener = alb.addListener("Listener", { port: 80, open: true });

    const targetGroup = new elbv2.ApplicationTargetGroup(this, "WebTargetGroup", {
      vpc,
      port: 3000,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.INSTANCE,
    });
    listener.addTargetGroups("DefaultTarget", { targetGroups: [targetGroup] });

    // 4. Launch template: install Docker and run our image when a server starts
    const userData = ec2.UserData.forLinux();
    userData.addCommands(
      "dnf update -y",
      "dnf install -y docker",
      "systemctl start docker",
      "systemctl enable docker",
      "docker run -d -p 3000:3000 your-dockerhub-username/my-session-app:v1"
    );

    const launchTemplate = new ec2.LaunchTemplate(this, "WebLaunchTemplate", {
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      securityGroup: webSg,
      userData,
      role: ssmRole,
      associatePublicIpAddress: true, // needed to download the image from Docker Hub
    });

    // 5. Auto Scaling Group: between 1 and 4 servers
    const asg = new autoscaling.AutoScalingGroup(this, "WebAsg", {
      vpc,
      launchTemplate,
      minCapacity: 1,
      maxCapacity: 4,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    });
    asg.attachToApplicationTargetGroup(targetGroup);

    // Scaling rules: add servers when the app is busy
    asg.scaleOnCpuUtilization("CpuScaling", { targetUtilizationPercent: 40 });
    asg.scaleOnRequestCount("RequestScaling", { targetRequestsPerMinute: 20 }); // very low on purpose, for the demo

    // Print the load balancer URL after deploy
    new cdk.CfnOutput(this, "LoadBalancerDns", {
      value: alb.loadBalancerDnsName,
      description: "The DNS URL of your application load balancer",
    });
  }
}

Deploy it:

bash
cdk bootstrap --profile <profile-name>   # only the first time
cdk synth --profile <profile-name>
cdk deploy --profile <profile-name>

--profile is the name of the AWS profile you use. Make sure it has the right permissions. When the deploy finishes, CDK prints LoadBalancerDns. Copy it.

Tip: A server needs 2 to 3 minutes to start Docker and pass the health check. If the page shows an error at first, wait a little and try again.


Step 5: See the Bug Live

The ASG starts with 1 server, so first we need more than 1. There are two ways:

  • Temporarily set minCapacity: 2 (or desiredCapacity: 2) and deploy again, or
  • Create real load. One browser refresh is too small to trigger scaling, so use a load tool:
bash
npx autocannon -c 100 -d 300 http://<ALB-DNS-NAME>

Scaling is not instant. AWS needs a few minutes to notice the traffic and start new servers. Watch it under EC2 → Auto Scaling Groups → Activity.

Once you have 2 or more servers:

  1. Open the ALB URL in your browser.
  2. Refresh a few times.
  3. The bug appears: the counter keeps jumping back to 1, and the server name keeps changing.

Why did this happen?

  • By default, the ALB picks a server for every single request (round robin).
  • Request 1 goes to Server A. It saves your session in its own memory and gives you a cookie.
  • Request 2 goes to Server B. It does not know your cookie, so it starts a fresh session. Counter = 1.
  • Request 3 goes to Server C. Same story.

Each Server keep its own notebook, so counter restart at 1

Same browser, same cookie, but three servers with three separate notebooks. The counter restarts every time.

The browser also asks for /favicon.ico in the background, which uses up more turns. That is why you almost never see a 2.

How to prove the ALB is rotating requests

Run this in your terminal. Each request is independent, so you will see the server name rotate:

bash
for i in {1..12}; do curl -s http://<ALB-DNS-NAME>; echo; done

Other ways to check:

  • Open Session Manager (EC2 → Connect) on each server and run sudo docker logs -f $(sudo docker ps -q). You will see requests arriving on different servers one by one.
  • In EC2 → Target Groups → Monitoring, look at RequestCountPerTarget. Without stickiness it is almost equal across all servers.

Step 6: The Fixes

Option 1: Sticky Sessions (the quick fix)

Tell the ALB: "Send the same user to the same server every time." The ALB does this with its own cookie (AWSALB).

typescript
const targetGroup = new elbv2.ApplicationTargetGroup(this, "WebTargetGroup", {
  vpc,
  port: 3000,
  protocol: elbv2.ApplicationProtocol.HTTP,
  targetType: elbv2.TargetType.INSTANCE,
  stickinessCookieDuration: cdk.Duration.minutes(1),
});

Run cdk deploy. Now each browser keeps going to the same server and the counter climbs.

But this is only a band-aid:

  • If the server is removed (scale-in) or crashes, the user loses the session.
  • Traffic can become uneven, because some servers get heavier users.
  • Tools like curl that do not keep cookies still get spread around.

Option 2: Shared Session Store with Redis (the real fix)

Instead of every server keeping its own notebook, we give all servers one shared notebook: Amazon ElastiCache (Redis). Now it does not matter which server answers. They all read the same session.

With Redis every server reads the same notebook, so counter climbs

Now any server can answer. All of them read and update the same session in Redis, so the counter keeps going up.

2a. Change the app to use Redis

bash
npm install redis connect-redis

Replace app.js with this. Notice there is only one session setup now, and it uses Redis:

javascript
import os from "os";
import express from "express";
import session from "express-session";
import { RedisStore } from "connect-redis";
import { createClient } from "redis";

const app = express();

// Connect to the shared Redis
const client = createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379",
});
client.on("error", (err) => console.error("Redis error:", err));
await client.connect();

app.use(session({
  store: new RedisStore({ client }), // sessions now live in Redis
  secret: "my-super-secret-key",
  resave: false,
  saveUninitialized: true,
  cookie: { maxAge: 60000 }, // 1 minute
}));

app.get("/", (req, res) => {
  req.session.views = (req.session.views || 0) + 1;
  const host = os.hostname();
  console.log(`[${new Date().toISOString()}] handled by ${host}, views=${req.session.views}`);
  res.set("X-Server", host);
  res.send(`Visited ${req.session.views} times. (Server: ${host})`);
});

app.listen(3000, () => console.log("App running on port 3000"));

Important: Do not keep the old in-memory app.use(session(...)) block next to the Redis one. Two session setups in the same app will fight each other. Also call connect() only once.

To test on your laptop, start a local Redis first:

bash
docker run -d -p 6379:6379 redis:7-alpine
docker build -t your-dockerhub-username/my-session-app:v2 .
docker run -p 3000:3000 -e REDIS_URL=redis://host.docker.internal:6379 your-dockerhub-username/my-session-app:v2

2b. Add Redis to the CDK stack

Add this to the same stack file (do not create a new VPC or a separate stack, because the servers and Redis must live in the same network). Put it after webSg and before the launch template:

typescript
import * as elasticache from "aws-cdk-lib/aws-elasticache";

// Firewall for Redis: only our web servers may connect
const redisSg = new ec2.SecurityGroup(this, "RedisSg", {
  vpc,
  description: "Allow Redis from web servers only",
});
redisSg.addIngressRule(webSg, ec2.Port.tcp(6379), "Redis from web SG");

// The default VPC only has public subnets, so we use those.
// The security group is what keeps Redis private.
const subnetGroup = new elasticache.CfnSubnetGroup(this, "RedisSubnetGroup", {
  subnetIds: vpc.publicSubnets.map((s) => s.subnetId),
  description: "Subnet group for session Redis",
});

const redisCluster = new elasticache.CfnCacheCluster(this, "SessionRedisCluster", {
  engine: "redis",
  cacheNodeType: "cache.t3.micro",
  numCacheNodes: 1,
  vpcSecurityGroupIds: [redisSg.securityGroupId],
  cacheSubnetGroupName: subnetGroup.ref,
});
redisCluster.addDependency(subnetGroup);

Then update the user data so each container knows where Redis is, and use the new v2 image:

typescript
userData.addCommands(
  "dnf update -y",
  "dnf install -y docker",
  "systemctl start docker",
  "systemctl enable docker",
  `docker run -d -p 3000:3000 -e REDIS_URL=redis://${redisCluster.attrRedisEndpointAddress}:6379 your-dockerhub-username/my-session-app:v2`
);

If you named the subnet group class CfnCacheSubnetGroup in an older CDK version, keep that name.

2c. Build, push and deploy

bash
docker build -t your-dockerhub-username/my-session-app:v2 .
docker push your-dockerhub-username/my-session-app:v2

cdk deploy --profile <profile-name>

Important: Pushing an image does not update servers that are already running. They pulled the image once, when they started. Because we changed the image tag to v2 in the user data, cdk deploy creates a new launch template version. Then start an instance refresh so the ASG replaces old servers with new ones:

bash
aws autoscaling start-instance-refresh --auto-scaling-group-name <your-asg-name>

You can find the ASG name in the EC2 console. You can also just terminate the old instances and let the ASG start fresh ones.

Using a version tag (v1, v2, v3) instead of latest is a good habit. Every deploy is traceable, and rollback is as easy as pointing to the old tag.


Step 7: The Final Result

Remove stickinessCookieDuration (if you added it) so we know Redis is doing the work, then:

  1. Open the ALB URL in your browser.
  2. Refresh many times.
  3. The magic: the server name keeps changing, but the counter keeps going up: 1, 2, 3, 4...

The session no longer lives on any one server. Servers can come and go, and users do not notice. Our application layer is now stateless.

Sticky sessions vs Redis

Sticky SessionsRedis
Setup effortVery smallMedium
Survives a server crashNoYes
Survives scale-inNoYes
Traffic spreadCan be unevenEven
Extra costNoneRedis node

Clean Up (Do Not Skip!)

This setup costs money while it is running (EC2, ALB, Redis). When you finish the demo:

bash
cdk destroy --profile <profile-name>

Key Takeaways

  • A load balancer sends each request to any server, so servers must not keep user state in their own memory.
  • Local sessions work with one server but break when you scale out.
  • Sticky sessions hide the problem. Shared storage like Redis solves it.
  • Stateless servers are easy to scale, replace and recover.

Production note: Real apps should keep the session secret in AWS Secrets Manager or an environment variable, never in code. Production Redis should also use encryption in transit, and ideally a replica for high availability.

Ready to try AdvikPrep?

Practice with expert-curated exam sets, instant grading, and AI-driven insights. Free to start — no credit card required.