Moving a Go service with the Railway Postgres and Redis templates, variable references and a cron service into your own AWS account.
A Go API on Railway usually looks the same: one service built from the repository, a Postgres template and a Redis template on the project canvas, and variables such as DATABASE_URL=${{Postgres.DATABASE_URL}} that wire them together. Scheduled work runs as a separate cron service that starts, does its job and exits. It is a fast way to ship, and it starts to pinch when a customer needs the data in your own AWS account, when you want backups and failover you do not operate yourself, or when the rest of the company already runs on AWS.
This guide moves a small Go product catalog off Railway. The app reads products from Postgres, caches them in Redis and calls an email API with a key stored in a Railway variable. It covers the mapping, the Encore (a tool that works alongside your framework) version of the service, the manual AWS path, the data move and the DNS switch.
Each option is covered below, starting with Encore.
Encore combines a tool that works alongside your framework for Go and TypeScript with a platform that deploys into your own AWS or GCP account. Each box on the Railway canvas becomes a declaration in the service that uses it, and the variable references disappear because the connections come from the declarations:
// Service catalog was the Railway web service. It stores products in
// Postgres and caches reads in Redis.
package catalog
import (
"context"
"errors"
"time"
"encore.dev/beta/errs"
"encore.dev/storage/cache"
"encore.dev/storage/sqldb"
)
// Replaces the Railway Postgres template: RDS on AWS, local Postgres under encore run.
var db = sqldb.NewDatabase("catalog", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// Replaces the Railway Redis template: ElastiCache on AWS, local Redis under encore run.
var cluster = cache.NewCluster("catalog-cache", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
PriceCents int `json:"priceCents"`
SKU *string `json:"sku"`
Cached bool `json:"cached"`
}
// Cached products expire after five minutes.
var products = cache.NewStructKeyspace[int64, Product](cluster, cache.KeyspaceConfig{
KeyPattern: "product/:key",
DefaultExpiry: cache.ExpireIn(5 * time.Minute),
})
type CreateParams struct {
Name string `json:"name"`
PriceCents int `json:"priceCents"`
SKU *string `json:"sku"`
}
//encore:api public method=POST path=/products
func Create(ctx context.Context, p *CreateParams) (*Product, error) {
prod := &Product{Name: p.Name, PriceCents: p.PriceCents, SKU: p.SKU}
err := db.QueryRow(ctx, `
INSERT INTO products (name, price_cents, sku) VALUES ($1, $2, $3)
RETURNING id`, p.Name, p.PriceCents, p.SKU).Scan(&prod.ID)
return prod, err
}
//encore:api public method=GET path=/products/:id
func Get(ctx context.Context, id int64) (*Product, error) {
// Cache-aside: serve from Redis when we can.
if p, err := products.Get(ctx, id); err == nil {
p.Cached = true
return &p, nil
} else if !errors.Is(err, cache.Miss) {
return nil, err
}
var p Product
err := db.QueryRow(ctx, `
SELECT id, name, price_cents, sku FROM products WHERE id = $1`, id,
).Scan(&p.ID, &p.Name, &p.PriceCents, &p.SKU)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "product not found"}
} else if err != nil {
return nil, err
}
if err := products.Set(ctx, id, p); err != nil {
return nil, err
}
return &p, nil
}
type UpdatePriceParams struct {
PriceCents int `json:"priceCents"`
}
//encore:api public method=PATCH path=/products/:id
func UpdatePrice(ctx context.Context, id int64, p *UpdatePriceParams) error {
if _, err := db.Exec(ctx, `UPDATE products SET price_cents = $1 WHERE id = $2`, p.PriceCents, id); err != nil {
return err
}
// Drop the cached copy so the next read sees the new price.
_, err := products.Delete(ctx, id)
return err
}
The two migrations in catalog/migrations recreate the table that already exists on Railway, which is what lets the data be restored into it later:
-- 1_create_products.up.sql
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
price_cents INT NOT NULL
);
-- 2_add_sku.up.sql
ALTER TABLE products ADD COLUMN sku TEXT;
CREATE UNIQUE INDEX products_sku_idx ON products (sku);
The email key that lived in a Railway variable becomes a secret, a field on var secrets struct { ResendAPIKey string } in the service that sends mail, set with encore secret set --type prod ResendAPIKey. Values that are not secret can be constants in the code. The caching docs cover the keyspace types, and the cache is not a general Redis connection, so check that your code only uses operations the keyspaces provide.
encore run starts Postgres and Redis locally and applies both migrations, replacing a laptop pointed at a shared Railway database. A Railway cron service becomes a cron.NewJob that calls an endpoint, which runs in the deployed environments; locally you call the endpoint by hand.
After connecting your AWS account and pushing, Encore provisions RDS, ElastiCache, the secrets in Secrets Manager and the compute in your own account, and gives the service its own IAM role. Migrations run before the new code goes live on each deploy. The Railway migration guide lists how every Railway concept maps.

For a team that writes code with AI agents, the canvas turns 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, REDIS_URL and the API key from environment variables. A non-root distroless image replaces Railway's automatic build:
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 /catalog ./cmd/catalog
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /catalog /catalog
EXPOSE 8080
ENTRYPOINT ["/catalog"]
Railway sets PORT for you, 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 and an ElastiCache cluster in private subnets, security groups that let only the service reach them, an ECR repository, an ECS service behind an Application Load Balancer, Secrets Manager entries referenced from the task definition and an execution role that can read them. The cron service becomes an EventBridge Scheduler schedule that runs an ECS task with its own IAM role. Most teams write this in Terraform so staging can be a second copy.
ElastiCache with in-transit encryption needs TLS on the client side, so a redis:// URL that worked on Railway's private network becomes rediss://. RDS requires TLS by default too, so keep sslmode=require in the connection string.
Stop writes by scaling down the Railway web service and pausing the cron service, along with any other service that writes to the database, then dump the database through the public connection URL shown in the Postgres service's variables (DATABASE_PUBLIC_URL) with pg_dump -Fc --data-only --no-owner, which writes the custom format that pg_restore reads. With Encore the schema already exists from the migrations, so restore only the data:
--data-only also restores the current value of products_id_seq, so new products continue from the highest id. Leave Redis behind: the cache-aside Get fills the new ElastiCache cluster from Postgres on the first read of each product. Check row counts 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 Railway. Keep the Railway project for a few days so you can switch back if something was missed. Leave the Railway cron service paused so it does not run against the old data.
Pick Encore when you want the Railway experience of pushing code and getting a running app, with the database, cache, secrets and scheduled jobs defined in the Go code and running in your own AWS account. Pick the manual ECS path when your team already runs Terraform for other AWS services and wants this app to use the same modules. See Encore vs Railway for a broader comparison.
Map each Railway service to an AWS one: the web service to a container service on Fargate, the Postgres template to RDS, the Redis template to ElastiCache, sensitive variables to Secrets Manager and the cron service to a scheduled job. Copy the data with pg_dump and pg_restore during a short write freeze, then switch DNS. With Encore, the database, cache and secrets are declared in the Go code and provisioned in your AWS account on deploy.
Store the connection string and API keys in Secrets Manager and reference them from the ECS task definition, which injects them as environment variables when the task starts. Each environment gets its own entries.
Usually not, if Redis is used as a cache. A cache-aside cache refills from Postgres on the first read of each key, so a new ElastiCache cluster can start empty. Redis used as a primary store or queue needs its own migration plan.
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.