DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Multi region frontend en AWS con Route 53 CloudFront y replicacion de datos

Un frontend en una sola región está bien para el 80% de los casos. Pero cuando tu app crece a usuarios globales, o cuando un cliente te pide SLA de 99.99%, una sola región ya no alcanza. Multi-region no es solo "desplegar en dos lugares", es una arquitectura donde Route 53 hace health checks, CloudFront sirve desde el edge más cercano, y los datos se replican con estrategias específicas según su naturaleza. Este artículo cubre la arquitectura completa.

Qué resuelve y qué no resuelve

flowchart TB
    subgraph Problemas[Problemas que resuelve]
        P1[Latencia geográfica]
        P2[Disaster recovery]
        P3[Compliance por región]
        P4[99.99% uptime real]
    end

    subgraph NoResuelve[Lo que NO resuelve]
        N1[Base de datos lenta]
        N2[Código con bugs]
        N3[Costos bajos]
        N4[Simplicidad operacional]
    end

    style Problemas fill:#2ecc71,color:#fff
    style NoResuelve fill:#e74c3c,color:#fff
Enter fullscreen mode Exit fullscreen mode

Multi-region no es primera solución. Es última. Primero optimiza single region: cache, índices DB, CDN. Cuando eso no alcanza, entonces multi-region.

Arquitectura objetivo

flowchart TB
    User[Usuarios globales] --> R53[Route 53<br/>Latency-based + Failover]

    R53 -->|US/LATAM| USEast[us-east-1<br/>Primary region]
    R53 -->|EU| EUWest[eu-west-1<br/>Secondary region]

    subgraph USEast
        CF1[CloudFront]
        L1[Lambda SSR]
        DDB1[(DynamoDB Global)]
        S31[S3 with replication]
    end

    subgraph EUWest
        CF2[CloudFront]
        L2[Lambda SSR]
        DDB2[(DynamoDB Global)]
        S32[S3 replicated]
    end

    DDB1 <-.->|Global Tables| DDB2
    S31 <-.->|Cross-region replication| S32

    style R53 fill:#8e44ad,color:#fff
Enter fullscreen mode Exit fullscreen mode

Route 53 con failover y latencia

El truco de Route 53 está en combinar dos tipos de routing:

  • Latency-based: manda al usuario a la región más cercana.
  • Failover: si la primary falla, redirige a la secondary.
// infra/dns-stack.ts
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';

export class DnsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: DnsStackProps) {
    super(scope, id, props);

    const zone = route53.HostedZone.fromLookup(this, 'Zone', {
      domainName: 'miempresa.com',
    });

    // Health check de la primary region
    const primaryHealthCheck = new route53.CfnHealthCheck(this, 'PrimaryHealth', {
      healthCheckConfig: {
        type: 'HTTPS',
        fullyQualifiedDomainName: props.primaryOriginDns,
        port: 443,
        resourcePath: '/api/health',
        requestInterval: 30,
        failureThreshold: 3,
        regions: ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-1'],
      },
      healthCheckTags: [{ key: 'Name', value: 'primary-health' }],
    });

    const secondaryHealthCheck = new route53.CfnHealthCheck(this, 'SecondaryHealth', {
      healthCheckConfig: {
        type: 'HTTPS',
        fullyQualifiedDomainName: props.secondaryOriginDns,
        port: 443,
        resourcePath: '/api/health',
        requestInterval: 30,
        failureThreshold: 3,
      },
    });

    // Latency record: primary (us-east-1)
    new route53.CfnRecordSet(this, 'PrimaryLatency', {
      hostedZoneId: zone.hostedZoneId,
      name: 'app.miempresa.com',
      type: 'A',
      setIdentifier: 'primary-us-east-1',
      aliasTarget: {
        dnsName: props.primaryDistributionDomain,
        hostedZoneId: 'Z2FDTNDATAQYW2',
      },
      region: 'us-east-1',
      healthCheckId: primaryHealthCheck.attrHealthCheckId,
    });

    // Latency record: secondary (eu-west-1)
    new route53.CfnRecordSet(this, 'SecondaryLatency', {
      hostedZoneId: zone.hostedZoneId,
      name: 'app.miempresa.com',
      type: 'A',
      setIdentifier: 'secondary-eu-west-1',
      aliasTarget: {
        dnsName: props.secondaryDistributionDomain,
        hostedZoneId: 'Z2FDTNDATAQYW2',
      },
      region: 'eu-west-1',
      healthCheckId: secondaryHealthCheck.attrHealthCheckId,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Con esta config, un usuario en París va a eu-west-1. Si eu-west-1 falla, Route 53 lo manda a us-east-1.

Health check endpoint

El endpoint debe probar TODAS las dependencias, no solo responder 200:

// app/api/health/route.ts
import { NextResponse } from 'next/server';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3';

const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const s3 = new S3Client({});

export async function GET() {
  const checks: Record<string, { status: 'ok' | 'fail'; duration: number; error?: string }> = {};

  // Check DynamoDB
  const dynStart = Date.now();
  try {
    await dynamo.send(
      new GetCommand({
        TableName: process.env.POSTS_TABLE,
        Key: { id: '__health_check__' },
      })
    );
    checks.dynamodb = { status: 'ok', duration: Date.now() - dynStart };
  } catch (error: any) {
    checks.dynamodb = { status: 'fail', duration: Date.now() - dynStart, error: error.message };
  }

  // Check S3
  const s3Start = Date.now();
  try {
    await s3.send(new HeadBucketCommand({ Bucket: process.env.ASSETS_BUCKET }));
    checks.s3 = { status: 'ok', duration: Date.now() - s3Start };
  } catch (error: any) {
    checks.s3 = { status: 'fail', duration: Date.now() - s3Start, error: error.message };
  }

  const allOk = Object.values(checks).every(c => c.status === 'ok');

  return NextResponse.json(
    {
      status: allOk ? 'healthy' : 'degraded',
      region: process.env.AWS_REGION,
      checks,
      timestamp: new Date().toISOString(),
    },
    { status: allOk ? 200 : 503 }
  );
}
Enter fullscreen mode Exit fullscreen mode

El status code es crítico: 503 hace que Route 53 marque el health check como failed y cambie el routing.

DynamoDB Global Tables

Para replicación de datos transparente:

// infra/data-stack.ts
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';

export class DataStack extends cdk.Stack {
  public readonly postsTable: dynamodb.TableV2;

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    this.postsTable = new dynamodb.TableV2(this, 'PostsTable', {
      tableName: 'posts',
      partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
      billing: dynamodb.Billing.onDemand(),
      dynamoStream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
      pointInTimeRecovery: true,
      replicas: [
        { region: 'eu-west-1' },
        { region: 'ap-southeast-1' },
      ],
      globalSecondaryIndexes: [
        {
          indexName: 'by-slug',
          partitionKey: { name: 'slug', type: dynamodb.AttributeType.STRING },
        },
      ],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Cuando escribes en la tabla de us-east-1, DynamoDB replica automáticamente a eu-west-1 y ap-southeast-1. Latencia típica: 1-2 segundos.

S3 cross-region replication

const primaryBucket = new s3.Bucket(this, 'PrimaryBucket', {
  versioned: true, // Requerido para replication
  encryption: s3.BucketEncryption.S3_MANAGED,
});

const replicaBucket = new s3.Bucket(this, 'ReplicaBucket', {
  versioned: true,
  encryption: s3.BucketEncryption.S3_MANAGED,
});

const replicationRole = new iam.Role(this, 'ReplicationRole', {
  assumedBy: new iam.ServicePrincipal('s3.amazonaws.com'),
});

primaryBucket.grantRead(replicationRole);
replicaBucket.grantWrite(replicationRole);

const cfnPrimaryBucket = primaryBucket.node.defaultChild as s3.CfnBucket;
cfnPrimaryBucket.replicationConfiguration = {
  role: replicationRole.roleArn,
  rules: [
    {
      id: 'replicate-to-eu',
      status: 'Enabled',
      priority: 1,
      destination: {
        bucket: replicaBucket.bucketArn,
        storageClass: 'STANDARD_IA',
        replicationTime: {
          status: 'Enabled',
          time: { minutes: 15 },
        },
        metrics: {
          status: 'Enabled',
          eventThreshold: { minutes: 15 },
        },
      },
      deleteMarkerReplication: { status: 'Disabled' },
      filter: { prefix: '' },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

El stack de región regional

El mismo stack se deploya en cada región:

// infra/regional-stack.ts
export class RegionalStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: RegionalStackProps) {
    super(scope, id, props);

    // Lambda SSR
    const ssrFn = new lambda.Function(this, 'SSR', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('../build'),
      memorySize: 1024,
      timeout: cdk.Duration.seconds(30),
      environment: {
        AWS_REGION_NAME: this.region,
        POSTS_TABLE: props.postsTableName,
        ASSETS_BUCKET: props.assetsBucketName,
      },
      architecture: lambda.Architecture.ARM_64,
    });

    // Permisos a DynamoDB Global Table
    ssrFn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: [
          'dynamodb:GetItem',
          'dynamodb:Query',
          'dynamodb:PutItem',
          'dynamodb:UpdateItem',
        ],
        resources: [
          `arn:aws:dynamodb:${this.region}:${this.account}:table/${props.postsTableName}`,
          `arn:aws:dynamodb:${this.region}:${this.account}:table/${props.postsTableName}/index/*`,
        ],
      })
    );

    const ssrUrl = ssrFn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
      invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
    });

    // CloudFront
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: new origins.FunctionUrlOrigin(ssrUrl),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
      },
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
    });

    new cdk.CfnOutput(this, 'DistributionDomain', {
      value: distribution.distributionDomainName,
      exportName: `${this.region}-distribution-domain`,
    });
  }
}

// Deploy en cada región
const app = new cdk.App();

new RegionalStack(app, 'RegionalUsEast', {
  env: { region: 'us-east-1', account: process.env.CDK_DEFAULT_ACCOUNT },
  postsTableName: 'posts',
  assetsBucketName: 'mi-empresa-assets-us-east-1',
});

new RegionalStack(app, 'RegionalEuWest', {
  env: { region: 'eu-west-1', account: process.env.CDK_DEFAULT_ACCOUNT },
  postsTableName: 'posts',
  assetsBucketName: 'mi-empresa-assets-eu-west-1',
});
Enter fullscreen mode Exit fullscreen mode

Manejo de sesiones cross-region

El problema: un usuario logea en us-east-1, su cookie sale de ahí. Si Route 53 lo manda a eu-west-1, la cookie debe ser válida.

Solución: firmar las sesiones con un secreto compartido vía Secrets Manager con replicación:

const sessionSecret = new secretsmanager.Secret(this, 'SessionSecret', {
  secretName: 'session-secret',
  replicaRegions: [
    { region: 'eu-west-1' },
    { region: 'ap-southeast-1' },
  ],
});
Enter fullscreen mode Exit fullscreen mode

En la app:

// lib/session.ts
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const sm = new SecretsManagerClient({ region: process.env.AWS_REGION });
let cachedSecret: string | null = null;

async function getSessionSecret(): Promise<string> {
  if (cachedSecret) return cachedSecret;

  const result = await sm.send(
    new GetSecretValueCommand({ SecretId: 'session-secret' })
  );

  cachedSecret = result.SecretString!;
  return cachedSecret;
}

export async function verifySession(token: string) {
  const secret = await getSessionSecret();
  return jwtVerify(token, new TextEncoder().encode(secret));
}
Enter fullscreen mode Exit fullscreen mode

Como el secret está replicado, ambas regiones pueden verificar la misma sesión.

Escribir con consistencia global

DynamoDB Global Tables son eventualmente consistentes. Si escribes en us-east-1 y lees inmediatamente en eu-west-1, puedes no ver el cambio.

Estrategia: escribir siempre en la región del usuario, pero si necesitas leer tu escritura, forzar lectura fuerte de esa región.

async function createPost(post: Post) {
  // Escribir localmente
  await dynamo.send(
    new PutCommand({
      TableName: 'posts',
      Item: post,
    })
  );

  // Si necesitamos confirmar la escritura, leer con consistency strong
  const read = await dynamo.send(
    new GetCommand({
      TableName: 'posts',
      Key: { id: post.id },
      ConsistentRead: true,
    })
  );

  return read.Item;
}
Enter fullscreen mode Exit fullscreen mode

Costos reales

Multi-region duplica mucho del costo. Ejemplo de un setup con 1M visitas/mes:

Componente Single region Multi-region (2)
Lambda $45 $48 (tráfico distribuido)
DynamoDB $120 $280 (replicación + writes x2)
CloudFront $85 $85 (mismo, es global)
S3 replication $0 $18
Route 53 health checks $0 $12
Total $250 $443

El costo sube ~75%. Hay que justificar ese número.

Failover testing

Nunca asumas que el failover funciona. Pruébalo:

# Simular fallo de primary region
aws route53 update-health-check \
  --health-check-id abc-123 \
  --disabled

# Esperar 2-3 minutos (tiempo de propagación DNS)
sleep 180

# Verificar que el tráfico se fue a secondary
dig app.miempresa.com +short
# Debería resolver al IP de eu-west-1

# Re-habilitar primary
aws route53 update-health-check \
  --health-check-id abc-123 \
  --no-disabled
Enter fullscreen mode Exit fullscreen mode

Un ejercicio mensual vale la pena. Las veces que fallará en producción, vas a estar preparado.

Lo que aprendí

1. Multi-region es para disaster recovery, no performance.

CloudFront ya te da edge locations globales. La única razón real para multi-region es DR.

2. La latencia de replicación es real.

DynamoDB Global Tables tarda 1-2 segundos. Para apps realtime (chats, dashboards), esto importa.

3. Los costos sorprenden.

Calcula antes. Las writes de DynamoDB se duplican (o triplican con 3 regiones). S3 replication traffic es pagado.

4. Route 53 failover no es instantáneo.

DNS caching en clientes puede tomar 5-10 minutos en propagarse totalmente, incluso con TTL bajo.

5. Datos que NO deben replicarse.

No todo: datos regulatorios (GDPR), datos de sesión, datos temporales. Piensa qué replicar y qué mantener regional.

Cuándo NO ir multi-region

  • Tráfico todavía manejable en una región.
  • No hay SLA explícito de 99.99%+.
  • Presupuesto limitado.
  • Equipo operacionalmente junior.

Para la mayoría de proyectos, CloudFront + una región bien configurada es más que suficiente.

Cierre

Multi-region frontend es una arquitectura madura para escenarios específicos. La complejidad operacional es real pero el payoff (uptime, latencia global) vale la pena cuando los números lo justifican. Route 53 + Global Tables + CloudFront te dan la base; la estrategia de datos y sesiones es lo que debes diseñar con cuidado.

En el próximo artículo: Remix en AWS con Architect para un stack alternativo a Next.js más liviano.

Top comments (0)