Oct 26, 20267 min read

How to Deploy an Axum App to Google Cloud Run

Shipping an Axum 0.8 service with Postgres to Cloud Run with Encore, a multi-stage Dockerfile and gcloud, or Terraform, with a non-root image, Cloud SQL, graceful shutdown and fast cold starts.

Axum services compile to a single binary that starts in milliseconds, which suits Cloud Run's scale-to-zero model. Most of the setup is a cached Docker build, the Cloud SQL connection and a service account with its roles, repeated for every environment.

This guide deploys an Axum 0.8 notes service with Postgres to Cloud Run three ways. It covers Encore, a multi-stage Dockerfile with gcloud, and Terraform.

What you need to decide:

  • How the image is built and how small it is.
  • How the service reaches Cloud SQL and with which identity.
  • How migrations run on deploy.
  • How each environment gets its own copy.

Axum on Cloud Run options at a glance

CapabilityEncoreDockerfile with gcloudTerraform
Image buildBuilt by Encore on pushYour Dockerfile, built by Cloud BuildYour Dockerfile, built in CI
Cloud SQLProvisioned from the declarationCreate and attach with gcloudgoogle_sql_database_instance
MigrationsApplied on deploy from ./migrationsYour own stepYour own step
Service identityCreated per service with the roles it needsYou create itYou define it
Local developmentencore run starts PostgresYou run itYou run it

Encore: deploy Axum to Cloud Run from your code

Encore

Encore combines the infrastructure your Axum code declares with a local environment and a deployment to Cloud Run in your own GCP project, so Cloud SQL, the service account and the service itself come from the same code you run locally. It is an infrastructure SDK, the encore-dev crate, added to an existing app, and the router and handlers are plain Axum 0.8.

Encore declares infrastructure in code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.
[dependencies] encore-dev = "0.1" axum = "0.8" serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }

The migration sits in src/migrations, next to the file that declares the database:

-- src/migrations/1_create_notes.up.sql CREATE TABLE notes ( id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL );

The service declares the database as a static, queries it with query_one and query_opt, and maps a missing row to 404. main binds the address Encore assigns and uses Encore's shutdown signal, which also covers the SIGTERM Cloud Run sends before it stops an instance:

use axum::{extract::Path, http::StatusCode, routing::{get, post}, Json, Router}; use encore::storage::postgres::Database; use serde::{Deserialize, Serialize}; // Cloud SQL for PostgreSQL on GCP, a local Postgres database under encore run. static DB: Database = Database::new("notes").migrations("./migrations"); #[derive(Deserialize)] struct NewNote { body: String, } #[derive(Serialize)] struct Note { id: i64, body: String, } async fn create_note(Json(note): Json<NewNote>) -> Result<(StatusCode, Json<Note>), StatusCode> { let row = DB .query_one( "INSERT INTO notes (body) VALUES ($1) RETURNING id", &[&note.body], ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok((StatusCode::CREATED, Json(Note { id: row.get("id"), body: note.body }))) } async fn get_note(Path(id): Path<i64>) -> Result<Json<Note>, StatusCode> { let row = DB .query_opt("SELECT id, body FROM notes WHERE id = $1", &[&id]) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(Note { id: row.get("id"), body: row.get("body") })) } #[tokio::main] async fn main() { encore::initialize(); let app = Router::new() .route("/notes", post(create_note)) .route("/notes/{id}", get(get_note)); // Encore sets ENCORE_LISTEN_ADDR; standalone, fall back to PORT, then 8080. let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or_else(|_| { let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); format!("0.0.0.0:{port}") }); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app) .with_graceful_shutdown(encore::shutdown::signal()) .await .unwrap(); std::process::exit(encore::shutdown::drained().await.code()); }

encore run builds the crate, starts Postgres locally and applies the migration:

~/notes
$ encore run$ curl -X POST localhost:4000/notes -H 'content-type: application/json' -d '{"body":"hello cloud run"}'{"id":1,"body":"hello cloud run"}$ curl -i localhost:4000/notes/9999HTTP/1.1 404 Not Found

Deploying means connecting your GCP project once and pushing. Encore builds the image and creates the Cloud Run service, a Cloud SQL for PostgreSQL instance with the migration applied, and a service account for the service, all in your GCP project. Pushes through the GitHub integration deploy each commit, and pull requests can get preview environments with their own database, which run in Encore's hosting rather than your project.

Because the database is part of the code, an agent adding a table writes the migration and the handler and runs them against a local Postgres. Cloud access sits with Encore's service identity in your project, not the agent.

Good to know

The migrations path is relative to the source file that declares the database. The 0.0.0.0:8080 fallback only applies when the binary runs outside Encore.

Try Encore →

Multi-stage Dockerfile with gcloud

Google Cloud's buildpacks do not include Rust in their list of supported languages, so Rust services ship with a Dockerfile. cargo-chef caches the dependency build in its own layer, and the runtime stage uses the distroless cc image as a non-root user:

FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef WORKDIR /app FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --bin notes FROM gcr.io/distroless/cc-debian12:nonroot COPY --from=builder /app/target/release/notes /usr/local/bin/notes USER nonroot ENTRYPOINT ["/usr/local/bin/notes"]

Outside Encore, ENCORE_LISTEN_ADDR is not set, so the binary binds to 0.0.0.0 on Cloud Run's PORT variable (8080 if unset), builds its pool from environment variables, and the database lives behind the Cloud SQL socket:

~/notes
$ gcloud sql instances create notes-db --database-version=POSTGRES_17 --tier=db-custom-1-3840 --edition=ENTERPRISE --region=europe-west1$ gcloud iam service-accounts create notes-api$ gcloud projects add-iam-policy-binding PROJECT --member=serviceAccount:notes-api@PROJECT.iam.gserviceaccount.com --role=roles/cloudsql.client$ printf '%s' "$DB_PASSWORD" | gcloud secrets create notes-db-password --data-file=-$ gcloud secrets add-iam-policy-binding notes-db-password --member=serviceAccount:notes-api@PROJECT.iam.gserviceaccount.com --role=roles/secretmanager.secretAccessor$ gcloud run deploy notes --source . --region=europe-west1 \ --service-account=notes-api@PROJECT.iam.gserviceaccount.com \ --add-cloudsql-instances=PROJECT:europe-west1:notes-db \ --set-secrets=DB_PASSWORD=notes-db-password:latest

Good to know

With --add-cloudsql-instances, the socket appears under /cloudsql/PROJECT:REGION:INSTANCE, and sqlx and tokio-postgres connect to it by setting the host to that directory. Migrations need their own step, for example sqlx migrate run in a Cloud Run job before the new revision takes traffic. Release builds of a service with many dependencies can take several minutes in Cloud Build, which cargo-chef shortens on later builds only when the layer cache is kept.

Terraform

For repeatable environments, the same service is written as Terraform with the v2 Cloud Run resource:

resource "google_cloud_run_v2_service" "notes" { name = "notes" location = "europe-west1" template { service_account = google_service_account.notes.email volumes { name = "cloudsql" cloud_sql_instance { instances = [google_sql_database_instance.notes.connection_name] } } containers { image = "europe-west1-docker.pkg.dev/${var.project}/apps/notes:${var.image_tag}" volume_mounts { name = "cloudsql" mount_path = "/cloudsql" } env { name = "DB_PASSWORD" value_source { secret_key_ref { secret = google_secret_manager_secret.db_password.secret_id version = "latest" } } } } } }

Good to know

This is one resource of many: the Cloud SQL instance, database and user, the service account and its IAM bindings, the Secret Manager secret and its accessor binding, the Artifact Registry repository and an IAM binding for public invocation. Each environment needs its own workspace or variables, and the image tag has to be passed in from CI.

Running Axum well on Cloud Run

Rust binaries start quickly, so most cold start time is pulling the image and opening the first database connection, and a small distroless image helps with the first. Turning on startup CPU boost gives the instance extra CPU while it starts. Keep the connection pool small, since Cloud Run can scale to many instances and each holds its own connections against the Cloud SQL limit. With Encore, the instance, service account and network setup are created in your own account.

How to choose

  • Encore if you want Cloud Run, Cloud SQL and the service account created in your GCP project from the Axum code, with Postgres running locally.
  • Dockerfile with gcloud for one service where a few commands per environment are acceptable.
  • Terraform if your team already manages GCP with it and wants every resource written out.

Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.

Frequently asked questions

How do I deploy an Axum app to Cloud Run?

Build a release binary in a multi-stage Dockerfile, copy it into a small non-root runtime image, bind to 0.0.0.0 on the PORT variable and deploy with gcloud run deploy. With Encore, declare static DB: Database = Database::new("notes").migrations("./migrations"), call encore::initialize() before axum::serve, connect your GCP project and push, and Encore builds the service and creates Cloud Run, Cloud SQL and the service account in that project.

Does Cloud Run support Rust without a Dockerfile?

Google Cloud's buildpacks do not list Rust among their supported languages, so Rust services on Cloud Run are normally deployed from a Dockerfile, either built locally, in CI or by gcloud run deploy --source.

How should an Axum app shut down on Cloud Run?

Cloud Run sends SIGTERM before stopping an instance, so pass a shutdown future to axum::serve with with_graceful_shutdown and let in-flight requests finish. With Encore, encore::shutdown::signal() provides that future.

How do I keep Rust cold starts low on Cloud Run?

Ship a small image with only the binary, avoid heavy work before binding the port, and enable startup CPU boost. A minimum instance count removes cold starts for latency-sensitive services at the cost of an always-on instance.

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