All posts

Moving an app from DigitalOcean App Platform to Cloud Run

App Platform bills each container all month with a traffic allowance; Cloud Run bills while requests run. What each part of the app spec becomes, the commands, and the bill on both sides.

Moving from DigitalOcean App Platform to Cloud Run turns each part of the app spec into a Google Cloud resource: services into Cloud Run services, workers into worker pools, its jobs into Cloud Run jobs, the database into Cloud SQL. The bill changes shape: App Platform charges $5 to $392 a month per container, Cloud Run only while requests run, so idle apps get cheaper and busy ones dearer.

In short

  • App Platform's smallest container is $5 a month with 50 GiB of traffic and $0.02 per GiB past it; Cloud Run charges $0.105 per GiB to users in Europe, with no allowance.
  • In our model Cloud Run costs $0.49 against $5.00 at 100,000 requests a month, $14.61 against $20.00 at 1 million with Postgres, and $146.27 against $36.63 at 10 million and 1,000 GB.
  • Cloud Run kills a container 10 seconds after SIGTERM; App Platform waits 120 by default.
  • Encrypted EV[1:...] variables cannot be decrypted outside DigitalOcean, so values come from their source or the running container.
  • Scheduled jobs move to Cloud Scheduler, $0.10 a job a month after 3 free, with no 15-minute minimum.

Prices are list prices of October 11, 2026, Cloud Run in europe-west1.

When moving pays, and when it does not

App Platform's price is per container. The smallest, apps-s-1vcpu-0.5gb, is $5 a month with 50 GiB of outbound traffic; apps-s-1vcpu-1gb is $12 with 150 GiB; dedicated-CPU plans run from $29 to $392 with up to 900 GiB. The $5 and $10 plans are fixed: one container, no scaling. Traffic past the allowance costs $0.02 per GiB, and the allowance is pooled across every app of your team. Containers are billed by the second, so a container that runs all month costs its full price. Cloud Run charges for the seconds your code handles requests and starts no instance when there are none.

Cloud Run's free tier is per billing account: 180,000 vCPU-seconds, 360,000 GiB-seconds and 2 million requests a month. Past it, a service with request-based billing pays $0.000024 per vCPU-second and $0.0000025 per GiB-second while it handles requests, and $0.40 per million requests (Tier 1 regions). Traffic to users in Europe is $0.105 per GiB from the first gigabyte, five times App Platform's overage price, with no allowance; the egress guide explains the lines.

Six bars: at 100,000 requests and 5 GB, App Platform costs $5.00 and Cloud Run $0.49; at 1 million requests and 50 GB with the smallest managed Postgres, App Platform $20.00 and Cloud Run with Cloud SQL $14.61; at 10 million requests and 1,000 GB, App Platform $36.63 and Cloud Run $146.27
One app on App Platform and on Cloud Run in europe-west1, list prices of October 11, 2026, a 30-day month. Cloud Run is an upper bound: no request shares an instance with another

The model is one web app. Every request takes 200 ms, of which 50 ms is CPU; the Cloud Run service has 1 vCPU and 512 MiB.

  • 100,000 requests and 5 GB a month. App Platform: one $5 container. Cloud Run: the compute stays inside the free tier, and 4.7 GiB of egress costs $0.49.
  • 1 million requests and 50 GB, plus Postgres. App Platform: the $5 container, whose 50 GiB allowance covers the traffic, and a single-node managed Postgres with 1 GiB at $15: $20.00. Cloud Run: $0.48 of CPU and $4.89 of egress, plus a db-f1-micro Cloud SQL instance with 10 GiB of SSD at $9.24 (720 hours here; the Cloud SQL guide counts 730 hours and gets $9.37): $14.61. The micro instance has 0.6 GB of memory and is outside Cloud SQL's SLA; that guide has every size.
  • 10 million requests and 1,000 GB. App Platform: two $12 containers, so a deploy or a crash never leaves the app with none, and their 300 GiB allowance; the other 631 GiB cost $12.63: $36.63. Cloud Run: $43.68 of CPU, $1.60 of memory, $3.20 of requests and $97.79 of egress: $146.27. If requests overlap on an instance, the CPU line falls; the egress line does not.

So Cloud Run is cheaper for an app that is idle most of the day, and App Platform is cheaper for an app that is busy all day or sends a lot of traffic. Other reasons to move are not on the chart: the rest of your system is on Google Cloud; you want Cloud SQL, Pub/Sub, BigQuery or IAM next to the app; you need a region App Platform lacks (it lists 13, Cloud Run runs in 43); or you want instances that appear with traffic instead of a count you pay for all month. Both platforms promise 99.95% a month. If none of these is your reason, the move costs time and saves nothing, and Exodus says so in its verdict.

What App Platform did for you

Everything App Platform runs is described in one file, the app spec. Download the live one, because the console may have changed it since the repository's copy was written:

doctl apps list --format ID,Spec.Name
doctl apps spec get APP_ID > app.yaml

A typical spec has a web service, a worker, a migration that runs before each deploy, a nightly job, a static site and a database:

name: shop
region: fra
services:
- name: web
  github: {repo: acme/shop, branch: main}
  http_port: 8080
  instance_size_slug: apps-s-1vcpu-1gb
  instance_count: 2
  envs:
  - {key: DATABASE_URL, value: "${db.DATABASE_URL}"}
  - {key: STRIPE_KEY, type: SECRET, value: "EV[1:...]"}
workers:
- name: queue
  github: {repo: acme/shop, branch: main}
  run_command: node worker.js
jobs:
- name: migrate
  kind: PRE_DEPLOY
  run_command: npx prisma migrate deploy
- name: nightly
  kind: SCHEDULED
  schedule: {cron: "0 3 * * *", time_zone: Europe/Berlin}
  run_command: node report.js
static_sites:
- name: docs
  output_dir: dist
databases:
- {name: db, engine: PG, production: true, cluster_name: shop-db}

Each component needs a home on Google Cloud, and some of them become more than one thing.

Ten pairs: services become Cloud Run services; workers become worker pools; PRE_DEPLOY jobs become a Cloud Run job run between build and deploy; SCHEDULED jobs become a job started by Cloud Scheduler; static sites become Firebase Hosting; databases become Cloud SQL; SECRET variables become Secret Manager; Spaces buckets become Cloud Storage; internal URLs, ingress routes and domains become service URLs with ID tokens and a load balancer or Firebase Hosting
Where each part of an App Platform app goes on Google Cloud

Step 1: the container

If the repository has a Dockerfile, App Platform built it, and Cloud Run builds the same file. Without one, App Platform used Cloud Native Buildpacks, for Node.js the Heroku buildpack; gcloud run deploy --source . uses Google's buildpacks, which detect the same languages and read a Procfile if you have one. If the spec sets run_command or build_command, put them in a Procfile (web: npm start) or in package.json scripts, because Cloud Run does not read the spec. The buildpacks guide covers the choice.

Three runtime details differ.

The port. App Platform sends traffic to http_port, 8080 by default, and sets PORT to it. Cloud Run does the same with 8080 and PORT; deploy with --port if the app listens elsewhere. Both need the app to listen on 0.0.0.0.

The stop signal. Both platforms send SIGTERM before they stop a container. App Platform then waits grace_period_seconds, 120 by default and up to 600, before SIGKILL. Cloud Run waits 10 seconds, and bills the CPU meanwhile. Code that finishes a long job after SIGTERM worked on App Platform and is killed halfway on Cloud Run. Keep the shutdown under 10 seconds and move long work into jobs, which the Fly guide shows with a handler tested in Docker.

The disk. App Platform gives each container up to 4 GiB of local disk, lost on every deploy. On Cloud Run the writable file system lives in memory: a file of 300 MB written to /tmp takes 300 MB of the instance's memory until it is deleted. Code that downloads large files to disk needs more memory, or streams.

Step 2: environment variables and secrets

Plain variables (type: GENERAL) are in the spec in clear text; they go on the service with --set-env-vars.

Encrypted variables are not. App Platform encrypts a SECRET value when you save it and keeps only the encrypted form, EV[1:...], in the spec; nothing decrypts it outside DigitalOcean. The value exists in clear text in one place: the running container. DigitalOcean's documentation says plainly that team members with console access can read it there. So the values come either from where they were made (the payment provider's dashboard, the OAuth app, the database) or from the console.

From the console, doctl apps console APP_ID web opens a shell in the web container, and printenv STRIPE_KEY prints the value on your screen. On your own machine, read it without echo and pipe it into Secret Manager, so it never lands in a file or in your shell history:

gcloud iam service-accounts create web
read -rs VALUE && printf %s "$VALUE" | gcloud secrets create STRIPE_KEY --data-file=- ; unset VALUE
gcloud secrets add-iam-policy-binding STRIPE_KEY \
  --member=serviceAccount:web@PROJECT_ID.iam.gserviceaccount.com \
  --role=roles/secretmanager.secretAccessor
gcloud secrets versions access latest --secret=STRIPE_KEY | wc -c

read -rs waits for you to paste the value and press Enter, without showing it; printf %s adds no newline. The last command prints only the length, to compare with the original. Anyone who could open the console could read these values all along, so a move is a good moment to rotate the ones that matter.

Bindable variables need real values. ${db.DATABASE_URL}, ${db.HOSTNAME}, ${APP_URL}, ${APP_DOMAIN} and the like are filled in by App Platform at deploy time; on Cloud Run they are just strings. Search the spec for ${ and give each one a value: the Cloud SQL connection string (Step 6), the service's URL, the domain.

Step 3: deploy and scaling

Pick the region next to the database. For an app in fra, europe-west3 is Frankfurt; for ams, europe-west4 is the Netherlands; europe-west1 in Belgium is a Tier 1 region with the prices used above. The region guide covers the trade-offs.

gcloud run deploy web --source . --region europe-west1 \
  --service-account=web@PROJECT_ID.iam.gserviceaccount.com \
  --allow-unauthenticated \
  --cpu=1 --memory=1Gi \
  --max-instances=10 \
  --set-env-vars=NODE_ENV=production \
  --set-secrets=STRIPE_KEY=STRIPE_KEY:latest

--source . builds the app in Cloud Build and deploys it, as the Vercel guide describes. The output ends with a run.app URL; test the app there before the domain moves.

The scaling settings map onto different defaults:

  • Instance count. instance_count was the number of containers you paid for, all month. Cloud Run has no such number: it starts instances as requests arrive, up to 100 per service by default, and stops them when traffic ends. Set --max-instances to the most you will pay for, and to protect a database with a fixed number of connections.
  • Autoscaling. autoscaling with min_instance_count becomes --min-instances. On App Platform the minimum is at least 1; on Cloud Run 0 is the default, and each instance kept warm is billed at the idle rate. An app that must answer the first request quickly keeps one.
  • Scale to Zero, App Platform's inactivity sleep, is labeled "private" in DigitalOcean's docs, so check that your account has it, and bills a sleeping service at 10% of its price. On Cloud Run an idle service with request-based billing costs nothing.
  • Size. instance_size_slug becomes --cpu and --memory.
A table comparing App Platform and Cloud Run: idle app, more instances, stop signal, local disk, protocols, cron, regions and traffic out
What changes in a move, from DigitalOcean's and Google's pages of October 11, 2026

Step 4: workers, deploy jobs and cron

Workers are App Platform components that take no HTTP traffic and run all the time, usually a queue consumer. On Cloud Run that is a worker pool, deployed from the same image, with a fixed number of instances that run all the time, like the worker did:

gcloud run worker-pools deploy queue --source . --region europe-west1 \
  --command=node --args=worker.js --instances=1 \
  --service-account=web@PROJECT_ID.iam.gserviceaccount.com

Putting the worker inside the web service does not work: with request-based billing it gets no CPU between requests, and it runs on every instance. The services, jobs and worker pools guide explains the three kinds.

Deploy jobs. PRE_DEPLOY jobs ran a migration before App Platform released the new version. On Cloud Run the same command becomes a Cloud Run job, run between the build and the deploy. The Fly guide has the whole sequence (gcloud builds submit, jobs deploy, run deploy --image); for this spec the job step is, with IMAGE the tag built in that sequence:

gcloud run jobs deploy migrate --image "$IMAGE" --region europe-west1 \
  --service-account=web@PROJECT_ID.iam.gserviceaccount.com \
  --set-secrets=DATABASE_URL=DATABASE_URL:latest \
  --command=npx --args=prisma,migrate,deploy \
  --execute-now --wait

--wait makes the command fail when the migration fails, so a script or CI step stops before the deploy, as App Platform did. POST_DEPLOY jobs become a step after the deploy, and FAILED_DEPLOY jobs a step your CI runs when the deploy fails.

Scheduled jobs. SCHEDULED jobs become a Cloud Run job started by Cloud Scheduler. App Platform runs a schedule every 15 minutes at most; Scheduler takes any unix-cron line and its own time zone per job (--time-zone=Europe/Berlin). It costs $0.10 a job a month, with 3 jobs free per billing account. The Fly guide has the gcloud scheduler jobs create http command that calls a job's :run endpoint.

Step 5: static sites

App Platform serves static sites from a CDN, free for up to three static-only apps and free inside a paid app. Cloud Run can serve the files from the app's container, but every request then counts as a request and as egress. Firebase Hosting is the closer match: a CDN for the files, free up to 10 GB stored and 360 MB a day transferred, with rewrites that send some paths to a Cloud Run service:

{
  "hosting": {
    "public": "dist",
    "rewrites": [{"source": "/api/**", "run": {"serviceId": "web", "region": "europe-west1"}}]
  }
}

firebase deploy --only hosting uploads the folder. The same file replaces App Platform's ingress rules that sent /api to one component and / to another.

Step 6: the database

App Platform has two kinds. A dev database is a small Postgres for development, $7 a month; a managed database is a DigitalOcean Managed Databases cluster, from $15 a month for a single node with 1 GiB, or $30 plus a $30 standby for high availability. A note for planning: from October 15, 2026 new accounts, and from November 30 all accounts, cannot add standby nodes to new Standard Edition clusters; existing clusters keep theirs. Both kinds move to Cloud SQL the same way, with pg_dump and pg_restore during a short write freeze. The guides for a VPS, Docker Compose and ECS on Fargate restore their dumps with the commands below.

Create the Cloud SQL instance and database first. Managed clusters accept connections only from trusted sources, so add your computer's address in the cluster's Network Access tab or with doctl databases firewalls append CLUSTER_ID --rule ip_addr:YOUR_IP. Stop writes (scale the app's workers to zero and put the app in maintenance), then dump over TLS with the host, port and user from the cluster's Connection Details:

PGSSLMODE=require pg_dump -Fc --no-owner --no-acl \
  -h HOST -p PORT -U doadmin -d defaultdb -f app.dump

pg_dump asks for the password, so it never sits in a command line. Then restore through the Cloud SQL Auth Proxy, in a second terminal:

cloud-sql-proxy PROJECT_ID:europe-west1:INSTANCE --port 5433
pg_restore --no-owner --no-acl -h 127.0.0.1 -p 5433 -U postgres -d app app.dump

Check row counts on a few tables on both sides. On Cloud Run the service connects through a Unix socket: deploy with --add-cloudsql-instances=PROJECT_ID:europe-west1:INSTANCE, give the service account the Cloud SQL Client role, and set DATABASE_URL to postgresql://USER:PASSWORD@/DB?host=/cloudsql/PROJECT_ID:europe-west1:INSTANCE, stored in Secret Manager because it holds the password.

Step 7: Spaces buckets

Spaces speaks the S3 API, and gcloud storage can read from any S3-compatible service. Create a Spaces access key, put it in ~/.aws/credentials with an editor (aws_access_key_id and aws_secret_access_key under [default]), point gcloud at your Spaces region, and copy:

gcloud storage buckets create gs://BUCKET --location=europe-west1
gcloud config set storage/s3_endpoint_url https://fra1.digitaloceanspaces.com
gcloud storage rsync s3://SPACE gs://BUCKET --recursive
gcloud config unset storage/s3_endpoint_url
A terminal: gcloud points storage/s3_endpoint_url at a local S3-compatible server, lists three objects in s3://uploads, copies them with gcloud storage rsync, and a second rsync copies nothing and exits 0
The same commands on our machine, October 11, 2026, against a local S3-compatible server standing in for Spaces and a local folder standing in for the bucket. Progress lines are cut

We ran these commands against a local S3-compatible server. rsync copies only what changed, so run it once ahead of the move and again during the write freeze. Unset the endpoint afterwards, or every later s3:// command goes to DigitalOcean. Large buckets can go through Storage Transfer Service instead.

Check what serving the files will cost. Spaces includes 1,024 GiB of outbound traffic for $5 a month and charges $0.01 per GiB past it; Cloud Storage serving files to users in Europe is internet egress at Google's rates. An app that serves a lot of images or video may be better off leaving them on Spaces, or behind a CDN, and moving only the app.

Step 8: internal routing and the domain

Inside an app, components reached each other at http://web over App Platform's private network, with no authentication. Cloud Run has no such network. Each service has its own URL; a private one gets --ingress=internal and no --allow-unauthenticated, the caller sends a Google-signed ID token with the receiving service's URL as audience, and the caller's service account gets roles/run.invoker on the receiver. Search the code for http:// followed by component names, and give each an environment variable holding the new URL.

App Platform issued the certificate for your domain. On Google Cloud, Firebase Hosting can carry the domain and route paths to services (Step 5); a global external Application Load Balancer does the same with more control; Cloud Run's own domain mapping is in Preview. Custom domains and HTTPS on Cloud Run compares the three and their costs.

The switch follows the order in the Vercel guide: lower the TTL a day before, test on the run.app URL, add the new OAuth callback, make the certificate active, change DNS, keep the old app for a week. App Platform adds three things:

  1. Test every component on the run.app URL: sign-in, a payment in test mode, an upload, the scheduled job run by hand.
  2. Before the DNS change, freeze writes, copy the database (Step 6) and the buckets again (Step 7), point the services at the new ones and deploy.
  3. Keep the App Platform app for the week. Archiving it (maintenance: archive: true in the spec) stops the components' charges; the database and dedicated IPs are still billed. Then delete the app, the database and the Spaces.

What breaks most often

  • Shutdown code that took a minute on App Platform is killed after 10 seconds.
  • A bindable variable such as ${db.DATABASE_URL} reaches the app as a literal string.
  • A run_command from the spec is lost, and the buildpack starts the wrong process.
  • Large files written to /tmp use up the instance's memory.
  • A worker copied into the web service gets no CPU between requests.
  • Calls to http://api fail, because that name does not exist on Google Cloud.
  • No --max-instances, and a traffic spike opens more database connections than Postgres allows.
  • The endpoint setting stays on, and an unrelated s3:// command reaches DigitalOcean.

All of them show up when you test on the run.app URL, before users see them. The patterns that break on Cloud Run guide covers the rest.

Where Exodus comes in

Exodus's CLI reads the app spec, the Dockerfile or buildpack files and the code on your machine, with your coding agent reading the parts that need judgement. The report lists every component from the picture above with what it becomes: services, workers as worker pools, deploy jobs and their place in the pipeline, scheduled jobs, static sites, bindable variables to replace, internal URLs, Spaces buckets, shutdown handling. It estimates the month on Cloud Run for your traffic next to what App Platform charges now, egress and database included, and says plainly when staying is cheaper.

FAQ

Is Cloud Run cheaper than DigitalOcean App Platform?

For an app that is idle most of the day, yes; for a busy app or heavy traffic, no. In our model Cloud Run costs $0.49 against $5.00 at 100,000 requests a month, but $146.27 against $36.63 at 10 million requests and 1,000 GB, because egress has no allowance.

How do I get secret environment variables out of App Platform?

App Platform keeps only the encrypted form of a SECRET variable in the spec, and nothing decrypts it outside DigitalOcean. Take each value from where it was made, or read it in the running container through doctl apps console, then pipe it into Secret Manager without writing it to a file.

What replaces App Platform workers on Cloud Run?

A worker pool, deployed from the same image with a fixed number of instances that run all the time. Putting the worker inside the web service does not work: with request-based billing it gets no CPU between requests, and it runs on every instance.

Where do App Platform static sites go on Google Cloud?

Firebase Hosting is the closer match: a CDN for the files, free up to 10 GB stored and 360 MB a day transferred, with rewrites that send paths such as /api to a Cloud Run service. Serving the files from the app's container counts every file as a request and as egress.

Sources

37
  1. docs.digitalocean.comApp Platform pricing, DigitalOcean docs
  2. digitalocean.comApp Platform pricing page, DigitalOcean
  3. docs.digitalocean.comApp spec reference, DigitalOcean docs
  4. docs.digitalocean.comHow to use environment variables in App Platform, DigitalOcean docs
  5. docs.digitalocean.comHow to scale apps in App Platform, DigitalOcean docs
  6. docs.digitalocean.comHow to configure Scale to Zero in App Platform, DigitalOcean docs
  7. docs.digitalocean.comHow to configure app termination settings, DigitalOcean docs
  8. docs.digitalocean.comApp Platform limits, DigitalOcean docs
  9. docs.digitalocean.comHow to manage cron jobs and deployment jobs, DigitalOcean docs
  10. docs.digitalocean.comHow to manage internal routing, DigitalOcean docs
  11. docs.digitalocean.comHow to manage databases in App Platform, DigitalOcean docs
  12. docs.digitalocean.comApp Platform buildpacks, DigitalOcean docs
  13. docs.digitalocean.comApp Platform availability, DigitalOcean docs
  14. digitalocean.comApp Platform SLA, DigitalOcean
  15. docs.digitalocean.comPostgreSQL pricing, DigitalOcean docs
  16. docs.digitalocean.comHow to connect to PostgreSQL clusters, DigitalOcean docs
  17. docs.digitalocean.comSpaces pricing, DigitalOcean docs
  18. docs.digitalocean.comSpaces API reference, DigitalOcean docs
  19. docs.digitalocean.comHow to archive and restore an app, DigitalOcean docs
  20. cloud.google.comStorage Transfer Service pricing, Google
  21. docs.digitalocean.comdoctl apps reference, DigitalOcean docs
  22. cloud.google.comCloud Run pricing, Google
  23. exodus.elim.ioEgress on Google Cloud explained, Exodus
  24. cloud.google.comCloud SQL pricing, Google
  25. docs.cloud.google.comContainer runtime contract, Cloud Run
  26. docs.cloud.google.comCloud Run quotas and limits
  27. docs.cloud.google.comAbout Procfiles, Google Cloud buildpacks
  28. docs.cloud.google.comConnect from Cloud Run to Cloud SQL for PostgreSQL
  29. docs.cloud.google.comInteroperability with other storage providers, Cloud Storage
  30. cloud.google.comCloud Scheduler pricing, Google
  31. firebase.google.comFirebase pricing
  32. firebase.google.comServe dynamic content with Cloud Run, Firebase Hosting
  33. docs.cloud.google.comMap custom domains, Cloud Run
  34. docs.cloud.google.comAuthenticating service-to-service, Cloud Run
  35. cloud.google.comCloud Run SLA, Google
  36. docs.cloud.google.comCloud Run locations, Google
  37. Exodus team: doctl 1.178.0 and gcloud 587.0.0 help output; the sample app spec checked with doctl apps spec validate --schema-only; gcloud storage ls and rsync against a local S3-compatible server standing in for Spaces, run on Oct 11, 2026; the cost model and its arithmetic

Exodus moves your app to Cloud Run.

A free web cabinet and an open-source CLI. With your coding agent, they audit your app, show what it costs today and on Google Cloud Run, and move it there step by step.

Sign up for freeSee a sample report
  1. Install the CLIOne line in your terminal, once per machine.
  2. Give the prompt to your agentSign in with Google and copy one prompt. Your agent audits the app on your machine.
  3. Read the report, then moveThe bill today and on Cloud Run, what to change and in what order. The agent does the moving.

Read next

Consult an expert

For a move that is hard to make alone: many services, a large database, no room for downtime. An engineer who has moved production apps to Cloud Run reads what you send and replies by email.

Optional.

What the app does and what it runs on today.

Optional. Anything that would change the plan: compliance, regions, a contract that ends.

Your email goes to our engineers and nowhere else.