Oct 28, 20269 min read

How to Move a Flask App from Railway to AWS

Moving a Flask app's web service, cron service, Railway Postgres, volume and variables to your own AWS account, with Encore or with ECS and Terraform, including the pg_dump copy, the volume files and DNS.

Flask apps on Railway usually run as a web service with a Postgres service next to it, a volume for uploaded files, a cron service for cleanup jobs and a few service variables. Moving to your own AWS account replaces each of those with an AWS resource, and the volume needs a new home because containers on Fargate do not keep files between deploys.

This guide moves a notes app with all of those pieces, first with Encore and then by hand with ECS on Fargate, RDS, S3 and Terraform. It then covers copying Postgres, moving the volume files and switching DNS.

The web service is configured in railway.json:

{ "$schema": "https://railway.com/railway.schema.json", "deploy": { "startCommand": "gunicorn app:app --bind 0.0.0.0:$PORT", "preDeployCommand": ["flask db upgrade"], "healthcheckPath": "/healthz" } }

Its variables are DATABASE_URL=${{Postgres.DATABASE_URL}} and SENTRY_DSN, a volume is mounted at /app/data, and a second service, purge-old-notes, runs flask purge-old-notes with a cronSchedule of 0 4 * * *.

Railway to AWS mapping at a glance

RailwayEncoreManual AWS setup
Web serviceFargate service from the codeECS service, task definition, load balancer
Postgres servicepostgres.Database, RDSRDS, subnet group, security group
Volume at /app/dataobjects.Bucket, S3S3 bucket and a task role policy
Cron service with cronSchedulecron.Job calling a routeEventBridge Scheduler and a task
Service variablessecrets.SharedSecret, stored per environmentSecrets Manager and an IAM grant
preDeployCommandSQL migrations applied on deployOne-off ECS task in the pipeline

Encore: Railway's services as declarations in the Flask app

Encore

Encore combines an open source infrastructure SDK, added to the existing Flask app, with a platform that provisions what the code declares in your own AWS account. The database, bucket, schedule and secret become declarations in Python, and encore run starts Postgres locally along with a local object store, so uploads and signed URLs work on a laptop before the app runs against S3.

Encore declares infrastructure in Python code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in Python, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

The notes service declares its infrastructure at the top of the module, and the volume reads and writes become bucket calls:

import os from asgiref.sync import async_to_sync from encore import cron, secrets from encore.storage import objects, postgres from flask import Flask, abort, request from waitress import serve # Replaces the Railway Postgres service and ${{Postgres.DATABASE_URL}}. db = postgres.Database("notes", migrations="./migrations") # Replaces the Railway volume mounted at /app/data. attachments = objects.Bucket("attachments") # Replaces the SENTRY_DSN service variable. sentry_dsn = secrets.SharedSecret[str]("SentryDsn") # Replaces the Railway cron service with cronSchedule = "0 4 * * *". _ = cron.Job( "purge-old-notes", path="/internal/purge-old-notes", title="Delete notes older than a year", schedule="0 4 * * *", ) app = Flask(__name__) # The SDK's I/O methods are async. Waitress runs plain WSGI views, # so each view calls an async function through async_to_sync. async def insert_note(title: str) -> int: row = await db.query( "INSERT INTO notes (title) VALUES ($1) RETURNING id", title ).one() return row["id"] async def store_attachment(note_id: int, data: bytes, content_type: str) -> str: key = f"notes/{note_id}" await attachments.upload(key, data, content_type=content_type) await db.exec("UPDATE notes SET attachment_key = $1 WHERE id = $2", key, note_id) return key async def attachment_link(note_id: int) -> str | None: row = await db.query( "SELECT attachment_key FROM notes WHERE id = $1", note_id ).one() if row is None or row["attachment_key"] is None: return None return await attachments.signed_download_url(row["attachment_key"], ttl=300) async def delete_old_notes() -> int: result = await db.exec( "DELETE FROM notes WHERE created_at < now() - interval '365 days'" ) return result.rows_affected @app.post("/notes") def create_note(): return {"id": async_to_sync(insert_note)(request.json["title"])}, 201 @app.put("/notes/<int:note_id>/attachment") def upload_attachment(note_id: int): content_type = request.content_type or "application/octet-stream" key = async_to_sync(store_attachment)(note_id, request.get_data(), content_type) return {"key": key} @app.get("/notes/<int:note_id>/attachment") def attachment_url(note_id: int): url = async_to_sync(attachment_link)(note_id) if url is None: abort(404) return {"url": url} @app.post("/internal/purge-old-notes") def purge_old_notes(): return {"deleted": async_to_sync(delete_old_notes)()} @app.get("/healthz") def healthz(): return {"ok": True, "sentry": bool(sentry_dsn.value)} if __name__ == "__main__": host, _, port = os.environ.get("ENCORE_LISTEN_ADDR", "0.0.0.0:8000").rpartition(":") serve(app, host=host or "0.0.0.0", port=int(port))

The SDK calls go through asgiref.sync.async_to_sync, the bridge the Encore.py docs describe for synchronous code, so pyproject.toml lists asgiref next to flask, encore-dev and Waitress, which serves the app on the address Encore passes in ENCORE_LISTEN_ADDR. Gunicorn and $PORT are no longer needed. Running it locally:

~/notes
$ echo -n "https://public@sentry.example/1" | encore secret set SentryDsn$ encore run$ curl -X POST localhost:4000/notes -H 'content-type: application/json' -d '{"title":"Q4 plan"}'{"id":1}$ curl -X PUT localhost:4000/notes/1/attachment -H 'content-type: application/pdf' --data-binary @plan.pdf{"key":"notes/1"}$ curl localhost:4000/notes/1/attachment{"url":"http://127.0.0.1:35739/objects/attachments/notes/1?X-Goog-Algorithm=..."}

The signed URL points at the local object store under encore run and at S3 once deployed. Cron jobs do not run locally, so the purge is tested with curl -X POST localhost:4000/internal/purge-old-notes; the route is reachable by anything that can reach the service, and deleting year-old notes twice does no harm. For production, set the secret with encore secret set --type production SentryDsn, connect your AWS account and deploy. Encore creates RDS, the S3 bucket, the schedule and a Fargate service in your AWS account, and provisions an IAM role for the service. The Railway steps are also summarized in Encore's migration guide from Railway.

Agents can keep working on the app the same way after the move: a new upload route is one more bucket call, tested locally and created on deploy in your own account.

Good to know

Non-secret settings, such as a page size or a feature flag, stay as constants in the code or in Flask config, since only secrets get a declaration. Encore applies the SQL files in migrations/ locally and on deploy, replacing flask db upgrade; for an existing database the first file is a baseline generated from Railway's schema.

Try Encore →

Manual path: ECS on Fargate, RDS, S3 and Terraform

By hand, the web service becomes an ECS service built from an image that runs Gunicorn as a non-root user:

FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd --create-home --uid 10001 app USER app EXPOSE 8000 CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3"]

The volume becomes a bucket the task role can write to, and SENTRY_DSN becomes a secret the execution role can read:

resource "aws_s3_bucket" "attachments" { bucket = "notes-attachments-prod" } resource "aws_secretsmanager_secret" "sentry_dsn" { name = "notes/sentry-dsn" } resource "aws_iam_role_policy" "web_attachments" { role = aws_iam_role.web_task.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = "${aws_s3_bucket.attachments.arn}/*" }] }) } resource "aws_iam_role_policy" "read_sentry_dsn" { role = aws_iam_role.task_execution.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "secretsmanager:GetSecretValue" Resource = aws_secretsmanager_secret.sentry_dsn.arn }] }) }

The secret value is stored with aws secretsmanager put-secret-value --secret-id notes/sentry-dsn --secret-string "$SENTRY_DSN", which keeps it out of Terraform state.

Good to know

Around this sit the VPC and private subnets, a load balancer and target group, security groups between the load balancer, the tasks and RDS, an ECR repository and an EventBridge Scheduler rule that runs flask purge-old-notes as a task. The Flask code changes from file paths under /app/data to boto3 calls, and flask db upgrade becomes a one-off task in the deploy pipeline.

Moving the data and the volume

Railway Postgres cannot be attached to AWS directly, so the schema and data are copied. Enable public networking on the Postgres service so it gets a DATABASE_PUBLIC_URL, then generate the baseline migration and commit it as migrations/1_baseline.up.sql, so the first deploy creates the tables. The grep strips session settings that are not valid in a migration file:

~/notes
$ railway variable list --kv --service Postgres | grep DATABASE_PUBLIC_URL$ pg_dump --schema-only --no-owner --no-acl -d "$DATABASE_PUBLIC_URL" \ | grep -vE '^(SET |SELECT pg_catalog|\\(un)?restrict )' > migrations/1_baseline.up.sql

At cutover, archive the volume and stop the web service in one go, in a quiet window, so no upload lands between the two. railway down removes the latest deployment without deleting the service. Stop the cron service the same way:

~/notes
$ railway ssh --service web -- tar -cf /app/data/backup.tar -C /app/data notes \ && railway volume files download /backup.tar ./backup.tar \ && railway down --service web --yes$ railway down --service purge-old-notes --yes$ tar -xf backup.tar$ aws s3 sync ./notes "s3://$ATTACHMENTS_BUCKET/notes"

The bucket Encore created is listed in the S3 console of your AWS account. Files land under the same notes/<id> keys the code stores in attachment_key.

RDS sits in private subnets, so restore the database from an AWS CloudShell VPC environment in the Encore environment's VPC, with an inbound rule for port 5432 on the RDS security group from the CloudShell security group. Database credentials are on the Infrastructure page of the Encore dashboard under Database Cluster, Users, as described in managing database users:

CloudShell (VPC)
$ sudo dnf install -y postgresql18$ pg_dump -Fc --data-only --no-owner --no-acl -d "$DATABASE_PUBLIC_URL" -f railway-data.dump$ pg_restore --data-only --no-owner --no-privileges -j 4 \ -d "postgresql://$DB_USER:$DB_PASSWORD@$RDS_ENDPOINT/notes" railway-data.dump

The data-only dump includes sequence values, so new notes continue from the last Railway ID, and the pg_restore documentation explains the -j option for parallel restores. When tables reference each other through foreign keys, drop -j so tables load in dependency order. AWS describes the same approach in importing PostgreSQL data into RDS. Compare row counts, remove the temporary security group rule and turn off public networking on Railway Postgres.

Switching DNS

Lower the TTL on the existing record a day ahead. With Encore, add the domain under Settings, Custom Domains, choose the environment, and point a CNAME at custom-domain.encr.app, per the custom domains docs. Encore issues the certificate and the domain becomes Active once the CNAME resolves. Keep the Railway project until traffic on AWS has looked normal for a few days; redeploying the web service there is the rollback.

How to choose

  • Encore if you want Railway's Postgres, volume, cron service and variables replaced by declarations in the Flask code and created in your AWS account, with uploads and signed URLs working locally.
  • ECS, RDS, S3 and Terraform by hand if your team already maintains Terraform and wants to own each resource definition.

You can sign up for early access to Encore for Python to try it with your Flask project.

Frequently asked questions

How do I move a Flask app from Railway to AWS?

Map each Railway service to an AWS resource, deploy the app against a new RDS database and S3 bucket, then stop the Railway web service with railway down, copy Postgres with pg_dump and pg_restore, move the volume files to S3 and point your domain at AWS. With Encore, Railway Postgres, the volume, the cron service and service variables become postgres.Database, objects.Bucket, cron.Job and secrets.SharedSecret declarations in the Flask code, created in your AWS account on deploy.

What replaces a Railway volume on AWS?

Object storage. A Fargate task has no persistent disk of its own, so files written to the volume move to an S3 bucket. The Flask routes then upload to the bucket and serve files through short-lived signed URLs.

How do I connect to Railway Postgres from outside Railway?

Enable public networking on the Postgres service, which exposes DATABASE_PUBLIC_URL through a TCP proxy, and read it with railway variable list --kv --service Postgres. Traffic over the proxy counts as egress on your Railway bill.

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders