Move a Beanstalk web and worker environment to containers on Fargate: replace sqsd and cron.yaml, keep the RDS database, and cut over without downtime, with Encore or with ECS by hand.
AWS Elastic Beanstalk runs an app on EC2 instances it manages, with a load balancer, Auto Scaling group and optionally an RDS database, all configured through environment settings and .ebextensions files. Teams move to ECS Fargate to stop patching and resizing instances, to run several services without one environment each, and to deploy container images instead of platform-specific bundles.
This guide maps a typical Beanstalk setup (a web environment, a worker environment with cron.yaml, and an attached RDS database) to Fargate, starting with decoupling the database, then two ways to rebuild the compute: declare the web, worker and cron pieces in application code with Encore, or rebuild them with ECS and Terraform by hand.
A database created inside a Beanstalk environment shares its lifecycle, and deleting the environment can delete the database. AWS recommends decoupling it for production, and it is the first step of any migration. Either take a snapshot and restore it as a standalone instance, or use the Beanstalk console's database settings to retain the database when the environment is deleted. Note the instance's VPC and CIDR range, since the Fargate environment has to reach it.
Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. For Go and TypeScript it is a backend framework with built-in infrastructure: declare the database, the queue that replaces the worker environment and the schedules from cron.yaml in the code that uses them, import the existing RDS instance, and Encore provisions Fargate and the rest in your AWS account.
Run in your terminal to get started locally.
In Go, the web endpoint, the worker and the periodic task live in one service:
// orders/orders.go
package orders
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"time"
"encore.dev/cron"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
)
// The database the Beanstalk environment used. Pointing this at an imported
// RDS instance keeps the data where it is.
var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
type OrderPlaced struct {
OrderID int64
Email string
}
// Replaces the worker environment's SQS queue and sqsd daemon.
var OrderEvents = pubsub.NewTopic[*OrderPlaced]("order-placed", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
var _ = pubsub.NewSubscription(OrderEvents, "send-receipt",
pubsub.SubscriptionConfig[*OrderPlaced]{
Handler: sendReceipt,
RetryPolicy: &pubsub.RetryPolicy{
MinBackoff: 10 * time.Second,
MaxRetries: 5,
},
},
)
func sendReceipt(ctx context.Context, e *OrderPlaced) error {
if err := deliverReceipt(ctx, e); err != nil {
return err
}
_, err := db.Exec(ctx, `UPDATE orders SET receipt_sent = true WHERE id = $1`, e.OrderID)
return err
}
// deliverReceipt calls the email provider. OrderID is the idempotency key, so a
// redelivery does not send a second receipt. receipt_sent is set only after this returns.
func deliverReceipt(ctx context.Context, e *OrderPlaced) error {
endpoint := os.Getenv("RECEIPT_URL")
if endpoint == "" {
return fmt.Errorf("RECEIPT_URL is not set")
}
body, err := json.Marshal(map[string]any{"to": e.Email, "order_id": e.OrderID})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", strconv.FormatInt(e.OrderID, 10))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("email provider status %d", resp.StatusCode)
}
return nil
}
type PlaceOrderParams struct {
Email string
IdempotencyKey string
}
type PlaceOrderResponse struct {
ID int64
}
//encore:api public method=POST path=/orders
func PlaceOrder(ctx context.Context, p *PlaceOrderParams) (*PlaceOrderResponse, error) {
if p.IdempotencyKey == "" {
return nil, fmt.Errorf("idempotency key is required")
}
// The orders row is the outbox: it is committed before publish, and a retry
// with the same key returns that row. ResendReceipts republishes anything
// not yet marked sent, so a failed publish is not lost and does not insert again.
tx, err := db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
var id int64
var email string
err = tx.QueryRow(ctx, `
INSERT INTO orders (email, idempotency_key)
VALUES ($1, $2)
ON CONFLICT (idempotency_key) DO UPDATE SET email = orders.email
RETURNING id, email`, p.Email, p.IdempotencyKey).Scan(&id, &email)
if err != nil {
return nil, err
}
if email != p.Email {
return nil, fmt.Errorf("idempotency key does not match this order")
}
if err := tx.Commit(); err != nil {
return nil, err
}
if _, err := OrderEvents.Publish(ctx, &OrderPlaced{OrderID: id, Email: email}); err != nil {
return nil, err
}
return &PlaceOrderResponse{ID: id}, nil
}
// Replaces cron.yaml in the worker environment. Schedules are in UTC.
var _ = cron.NewJob("resend-receipts", cron.JobConfig{
Title: "Retry receipts that were not sent",
Schedule: "*/30 * * * *",
Endpoint: ResendReceipts,
})
//encore:api private
func ResendReceipts(ctx context.Context) error {
rows, err := db.Query(ctx, `SELECT id, email FROM orders WHERE NOT receipt_sent LIMIT 100`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var e OrderPlaced
if err := rows.Scan(&e.OrderID, &e.Email); err != nil {
return err
}
if _, err := OrderEvents.Publish(ctx, &e); err != nil {
return err
}
}
return rows.Err()
}
The handler that used to receive sqsd's POST on localhost becomes a Pub/Sub subscription with a retry policy and a dead-letter queue, and the cron.yaml entry becomes a cron job that the Encore platform calls once per schedule. encore run starts Postgres locally and applies the migrations, so the worker path can be tested on a laptop instead of in a deployed worker environment.
When you create the Encore environment on AWS, choose Fargate as the compute and Import Existing Database in the database section, as described in Import an existing RDS or Aurora database. Encore adds an inbound rule to the database's security group, sets up VPC peering if the database lives in the Beanstalk VPC, and creates its roles through the master user. It never changes the instance settings or deletes the database. Pick a CIDR range for the new VPC that does not overlap the Beanstalk VPC, which is often the default VPC at 172.31.0.0/16.
The existing schema becomes the first migration: dump it with pg_dump --schema-only --no-owner --no-privileges, save it as 1_bootstrap.up.sql, and mark it as applied on the imported database so new environments get the schema while production is left as it is:
CREATE TABLE IF NOT EXISTS schema_migrations (version bigint NOT NULL, dirty boolean NOT NULL);
INSERT INTO schema_migrations (version, dirty) VALUES (1, false);
Apps that already used golang-migrate have a schema_migrations table with the same two columns, and Encore applies only migration files with a higher version than the one recorded there.
Connect your AWS account in the Encore dashboard under Integrations > Connect Cloud, as described in Connect your cloud account, set the secrets that used to be environment properties with encore secret set --type prod, and run git push encore. Encore provisions the VPC, an Application Load Balancer, the Fargate services, SNS and SQS with a dead-letter queue for the subscription, and Secrets Manager entries, and it provisions an IAM role for the service, as listed in the AWS infrastructure docs. Beanstalk keeps serving traffic against the same database while you test the new environment on its own URL.
Best fit: teams moving a Beanstalk web and worker pair onto Fargate who want the queue and schedules declared in Go or TypeScript and the existing RDS instance imported as it is.
The direct route rebuilds each Beanstalk piece in Terraform or CDK. Write a Dockerfile for the app, push the image to ECR, and define a task definition with an execution role, a task role and the secrets that used to be environment properties. Create an ECS service on Fargate in private subnets behind an Application Load Balancer, with the health check path the Beanstalk environment used.
The worker environment needs the most change, because Beanstalk's sqsd daemon read the queue and POSTed each message to the app, deleting it on a 200 response, and on Fargate nothing does that. Either add a consumer loop with ReceiveMessage and DeleteMessage to the app, or run a small sidecar that reproduces the POST-to-localhost contract while you migrate. cron.yaml entries become EventBridge Scheduler schedules that start a task or call an endpoint, and Beanstalk's leader-only execution of periodic tasks has to be preserved by the schedule targeting one task.
.ebextensions settings split up: packages and files move into the Dockerfile, environment settings into the task definition, and load balancer and Auto Scaling options into the ECS service and target group. Security groups must let the tasks reach RDS on port 5432.
Best fit: teams with an existing ECS and Terraform setup who want the migrated app defined in the same modules as their other services.
Both routes run the Fargate service against the same RDS database while Beanstalk still serves traffic, which makes the switch a DNS change:
cron.yaml schedules from the Beanstalk worker before the new cron jobs go live, so periodic tasks do not run twice, and let the old worker drain its queue.If your infrastructure team already maintains ECS modules, rebuild the web service, sqsd worker and cron.yaml schedules with ECS, an ALB and EventBridge Scheduler, and keep the decoupled RDS instance. If you want those pieces declared in Go or TypeScript and provisioned on Fargate in your own AWS account, with the existing database imported, Encore covers that path.
Containerize the app, push the image to ECR and run it as an ECS service on Fargate behind an Application Load Balancer. Replace the worker environment's sqsd daemon with a consumer that polls SQS, move cron.yaml schedules to EventBridge Scheduler, translate .ebextensions settings into task definitions and Terraform, keep or decouple the RDS database, and switch DNS once the Fargate service is healthy.
Declare the database, a Pub/Sub topic and subscription for the worker queue and cron jobs for cron.yaml in Go or TypeScript, then create an Encore environment on AWS with Fargate as compute and import the existing RDS instance. Encore provisions the VPC, load balancer, Fargate services, SNS and SQS and the IAM role for each service in your account, and connects to the imported database without changing it.
If the database was created as part of the environment, deleting the environment deletes the database too, unless you change the retention setting or take a snapshot first. Decouple it before the migration by snapshotting and restoring it as a standalone instance, or by changing the database lifecycle setting so it is retained.
A service that consumes SQS directly replaces it, because in a Beanstalk worker environment the sqsd daemon reads the queue and POSTs each message to your app on localhost, deleting it on a 200 response. On Fargate the app reads from SQS itself, or a Pub/Sub library or framework delivers messages to a handler with retries and a dead-letter queue.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.