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:
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.

[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",
&[¬e.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:
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.
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.
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:
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.
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"
}
}
}
}
}
}
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.
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.
Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.
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.
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.
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.
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.