Moving a Go web dyno, Heroku Postgres, config vars and Heroku Scheduler jobs to your own AWS account.
Heroku was a natural home for a Go API: a Procfile with web: bin/orders, a Postgres add-on that sets DATABASE_URL, config vars for keys and Heroku Scheduler for the hourly cleanup. It starts to pinch when the database outgrows the plan you are on, when a customer asks for the data to live in your own AWS account, or when you want staging and preview copies that do not each cost another set of dynos and add-ons.
This guide moves a small Go orders API off Heroku. The app has a web dyno, Heroku Postgres, two config vars and an hourly Scheduler job that cancels stale orders. It covers the mapping, a tested Encore version of the service, the manual AWS path, the data move and the DNS switch.
Encore combines an open source infrastructure SDK for Go and TypeScript with a platform that deploys into your own AWS or GCP account. The pieces Heroku attached to the app from outside become declarations in the service. The database replaces the Postgres add-on, and the hourly job replaces the Scheduler entry:
// Service orders was the web process on the old platform.
package orders
import (
"context"
"errors"
"time"
"encore.dev/beta/errs"
"encore.dev/cron"
"encore.dev/storage/sqldb"
)
// Replaces the DATABASE_URL add-on: RDS on AWS, local Postgres under `encore run`.
var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// Replaces the scheduled cleanup task on the old platform.
var _ = cron.NewJob("expire-pending", cron.JobConfig{
Title: "Cancel orders left pending for a day",
Every: 1 * cron.Hour,
Endpoint: ExpirePending,
})
type Order struct {
ID int64 `json:"id"`
Customer string `json:"customer"`
TotalCents int64 `json:"totalCents"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateParams struct {
Customer string `json:"customer"`
TotalCents int64 `json:"totalCents"`
}
//encore:api public method=POST path=/orders
func Create(ctx context.Context, p *CreateParams) (*Order, error) {
if p.Customer == "" || p.TotalCents <= 0 {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "customer and totalCents are required"}
}
o := &Order{}
err := db.QueryRow(ctx, `
INSERT INTO orders (customer, total_cents) VALUES ($1, $2)
RETURNING id, customer, total_cents, status, created_at`,
p.Customer, p.TotalCents,
).Scan(&o.ID, &o.Customer, &o.TotalCents, &o.Status, &o.CreatedAt)
return o, err
}
//encore:api public method=GET path=/orders/:id
func Get(ctx context.Context, id int64) (*Order, error) {
o := &Order{}
err := db.QueryRow(ctx, `
SELECT id, customer, total_cents, status, created_at FROM orders WHERE id = $1`, id,
).Scan(&o.ID, &o.Customer, &o.TotalCents, &o.Status, &o.CreatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "order not found"}
}
return o, err
}
type ExpireResult struct {
Cancelled int64 `json:"cancelled"`
}
// ExpirePending is safe to run twice: it only touches rows still pending.
//
//encore:api private method=POST path=/orders/expire-pending
func ExpirePending(ctx context.Context) (*ExpireResult, error) {
res, err := db.Exec(ctx, `
UPDATE orders SET status = 'cancelled'
WHERE status = 'pending' AND created_at < now() - interval '24 hours'`)
if err != nil {
return nil, err
}
return &ExpireResult{Cancelled: res.RowsAffected()}, nil
}
The migration in orders/migrations/1_create_orders.up.sql matches the table that already exists on Heroku, which is what lets the data be restored into it later:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer TEXT NOT NULL,
total_cents BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Config vars that hold credentials become secrets, declared as fields on a var secrets struct in the service that uses them and set with encore secret set --type production. Values that are not secret can stay as constants in the code. encore run starts Postgres locally and applies the migration, which replaces pointing a laptop at a shared Heroku database:
Cron jobs do not run locally, so the cleanup endpoint is called by hand above. After connecting your AWS account and pushing, Encore provisions RDS, the secrets in Secrets Manager, the scheduled job and the compute in your own account, and provisions an IAM role for the service. Migrations run as part of each deploy, which covers what a Heroku release: line usually did. Encore's Heroku migration notes cover workers, add-ons and the other process types.
For a team that writes code with AI agents, the Heroku dashboard steps turn into code an agent can read and change in a pull request, and the agent can run the result with encore run before anyone deploys it.
Run in your terminal to get started locally.
Without Encore, the Go binary stays the same and reads DATABASE_URL and the API key from environment variables. A non-root distroless image replaces the Heroku Go buildpack:
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /orders ./cmd/orders
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /orders /orders
EXPOSE 8080
ENTRYPOINT ["/orders"]
Heroku sets PORT for the dyno, and on ECS you choose the container port yourself, so read it from configuration with a default. Then come the AWS resources: an RDS instance in private subnets, an ECR repository, an ECS cluster and service behind an Application Load Balancer, Secrets Manager entries for each credential referenced from the task definition, and an execution role that can read them. The hourly job becomes an EventBridge Scheduler schedule that runs an ECS task with a different command, which needs its own IAM role. Most teams write this in Terraform so staging can be a second copy.
Heroku Postgres requires TLS and so does RDS by default, so keep sslmode=require in the connection string. If the app ran migrations from a release: command, run them as a one-off ECS task before the service updates.
Freeze writes on Heroku with maintenance mode, then capture a backup with the Heroku CLI (heroku pg:backups:capture followed by heroku pg:backups:url to download it). Heroku backups use the custom format that pg_restore reads. With Encore the schema already exists from the migration, so restore only the data through a temporary connection string:
--data-only also restores the current value of the orders_id_seq sequence, so new orders continue from the highest id. Check row counts on a few tables before switching traffic. For the manual path, restore into RDS from a host inside the VPC, such as CloudShell in a VPC environment, since the instance is not publicly reachable.
Add the custom domain to the new environment, wait for its certificate, then point the DNS record away from the Heroku app. Keep the Heroku app and its database in maintenance mode for a few days, so you can switch back if something was missed, and remove the Scheduler job there so it does not run against the old data.
Pick Encore when you want the Heroku experience of pushing code and getting a running app, with the database, secrets and scheduled jobs defined in the Go code and running in your own AWS account, plus preview environments for each pull request. The same service can add Pub/Sub for work that used to run on a worker dyno.
Pick the manual ECS path when your team already runs Terraform for other AWS services and wants this app to use the same modules.
Map each Heroku piece to an AWS one: the web dyno to a container service, Heroku Postgres to RDS, config vars to Secrets Manager, and Heroku Scheduler to a scheduled job. Then copy the data with pg_dump and pg_restore during a short write freeze and switch DNS. With Encore, the database, secrets and cron jobs are declared in the Go code and provisioned in your AWS account on deploy.
Not as a managed import. Take a backup in pg_dump custom format, download it, and restore it into the new database with pg_restore. Create the schema with your migrations first and restore with --data-only, or restore schema and data together into an empty database.
EventBridge Scheduler triggering a Lambda function or an ECS task on a cron or rate expression.
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.