Google Cloud Run
Google Cloud Run is a container hosting. It is easy to use and provides sane defaults, e.g. it scales down to 0 when your app is not used, which makes it a cost-efficient option as well for review-apps and dev-apps.
It's compatible with all kinds of runtimes and frameworks, as it can run any Docker image.
Make sure to follow the CLOUD READY principles, as they are in particular important for cloud run.
Configuration
In your catladder.ts set the deploy type to google-cloud-run:
const config = {
customerName: "pan",
appName: "myapp",
components: {
api: {
dir: "api",
build: {
// ...
},
deploy: {
type: "google-cloudrun",
projectId: "your-google-cloud-project",
region: "europe-west6",
},
},
},
} satisfies Config;
Whenever you make a change to this config, run:
yarn catladder project setup
This will set up the required service-accounts, APIs, etc.
Make Sure that you are logged in with gcloud and have access to your-google-cloud-project
The .catladder-store folder
project setup also fetches the gcloud project number and writes it to
.catladder-store/store.yml. Catladder needs it to compute the deterministic
cloud run urls (https://<service>-<projectNumber>.<region>.run.app) at
generation time.
Check this folder in. It contains no secrets — only machine-fetched values
that pipeline generation depends on. If it is missing (e.g. on a freshly
cloned project that predates it, or after deleting it), catenv fails with an
error asking you to rerun project setup.
Service name and url
The service is deployed as
<customerName>-<appName>-<env>[-<reviewSlug>]-<componentName>, and
ROOT_URL / ROOT_URL_INTERNAL point at cloud run's deterministic url
https://<service>-<projectNumber>.<region>.run.app.
That url only exists while its first dns label — service name plus project number — stays within the 63 characters dns allows. Beyond that, cloud run silently serves a legacy url built from a random identifier that cannot be computed in advance, so catladder shortens the component part of the service name just enough to fit rather than generating a hostname that could never resolve.
Review environments carry a runtime slug (mr<iid> / pr<number>) on
top, for which 8 characters are reserved — a component name can
therefore fit in dev/prod and still be shortened in review.
Generation fails with an explicit error instead of shortening when two
components would end up with the same service name, or when
customerName/appName/env plus the project number already leave no room
for it.
Cloud SQL support
- Create a CloudSQL instance in the same project
your-google-cloud-project(if you need support for cross-project apps, please raise an issue, as this would need an additional service account).
Copy the password. Make sure the password does not contain special chars (this is a limitation in GitLab currently). - Add this to your deploy config:
deploy: {
type: "google-cloudrun",
projectId: "your-google-cloud-project",
region: "europe-west6",
cloudSql: {
type: 'unmanaged', // only type currently
instanceConnectionName: 'your-google-cloud-project:europe-west6:instance-name', // <-- copy it directly from the cloud sql console
}
}
- run
catladder project setupandcatladder project config-secrets
Catladder will automatically create the databases on this instance. You can reuse the same instance for multiple apps, as the database-names contain the full app name and should not clash.
To use a DB from another project, refer to this https://stackoverflow.com/a/70770872/1463534
Connection string env vars
With cloudSql configured, catladder injects CLOUD_SQL_INSTANCE_CONNECTION_NAME, DB_NAME, DB_USER, DB_PASSWORD plus ready-made connection strings DATABASE_URL and DATABASE_JDBC_URL (format via dbConnectionStringFormat: "prisma" (default), "rails" or "jdbc").
The connection strings embed the component's final values, so they are self-contained: you can reference them from another component (e.g. SECOND_DATABASE_URL: "${otherComponent:DATABASE_URL}") and overrides of the DB_* vars in vars.public flow into them.
Before catladder 5, the default mode kept placeholders like $DB_PASSWORD in the connection string (dbConnectionStringVariablesMode: "legacy"), which only expanded correctly inside the component's own jobs. The old behaviour is still available as an escape hatch by setting dbConnectionStringVariablesMode: "legacy", but it will be removed in a future major.
Reusing a database from another component
By default each component gets its own database. To point a component at another component's database, set dbBaseName to that component's name and reference its password:
worker: {
deploy: {
type: "google-cloudrun",
// ...
cloudSql: {
...CLOUD_SQL,
dbBaseName: "api", // use the api component's database
},
},
vars: {
public: {
// the db password lives in the api component's secret;
// this override also flows into DATABASE_URL / DATABASE_JDBC_URL
DB_PASSWORD: "${api:DB_PASSWORD}",
},
},
},
Jobs and migrations
Jobs are either for one-time work that needs to be done or for recurring tasks on a schedule (cronjobs)
Execute jobs before or after deployment
deploy: {
type: "google-cloudrun",
projectId: "your-google-cloud-project",
region: "europe-west6",
jobs: {
myjob: {
command: "your-command", // <-- by default it will use the same image as your app, but with another command/entrypoint
}
},
execute: {
myjobexecution: { // <-- you can choose any name here. To override an execution, use the same name again
type: "job",
job: "myjob",
when: "preDeploy", // can also be "postDeploy", "preStop", "postStop"
waitForCompletion: true, // <-- if true, the deployment will wait until the job is finished, defaults to false
}
}
}
Execute jobs on a schedule
You can run a cloud run job on a schedule:
deploy: {
type: "google-cloudrun",
projectId: "your-google-cloud-project",
region: "europe-west6",
jobs: {
["send-emails"]: {
command: "send-emails",
}
},
execute: {
"send-newsletter": {
type: "job",
job: "send-emails",
args: ["--newsletter"], // <-- optionally you can specify arguments
when: "schedule",
schedule: "0 0 * * *", // <-- cron syntax, here: every day at midnight
}
}
}
You can also call an http endpoint on a schedule. This is useful if you have a background worker that can be triggerd by an http request.
deploy: {
type: "google-cloudrun",
projectId: "your-google-cloud-project",
region: "europe-west6",
jobs: {
["send-emails"]: {
command: "send-emails",
}
},
execute: {
"send-newsletter": {
type: "http",
url: "${ROOT_URL}/send-emails", // <-- you can use variables here
when: "schedule",
method: "GET",
schedule: "0 0 * * *", // <-- cron syntax, here: every day at midnight
}
}
}
Execute a job from within your app
Check out the cloudrun-prisma test project (Panter-internal) for a working example.
Custom domains
There's no built-in support for custom domains in catladder, but you can set up custom domains manually.
Follow these guide: https://cloud.google.com/run/docs/mapping-custom-domains
Some hints:
- Google's load balancer is powerful, but expensive in comparison (at least 20 CHF per month).
Recommended for bigger projects. - Firebase hosting is a cheaper alternative. You can use firebase hosting without actually using all other features of it.
Only the specially-named__sessioncookie is permitted to pass through to the execution of your app (see docs). However, if you have a static app (E.g. frontend) as well, you might consider it as static hosting. - Cloud Run domain mapping is the third option, but it's not (yet) available in the Zurich region
Custom Domains using firebase
Firebase can be used as a cheap alternative to Google's load balancer to add custom domains to your cloud run app.
Preparation work:
- make sure you have access to the DNS of your domain
- make sure your app is deployed to production and works correctly there
- Create a firebase project. When asked for the name, let the dropdown load. It will show you existing google cloud projects. Make sure to select the same project as your cloud run app.
- under hosting, add a custom domain. Follow the instructions. You will need to add a TXT record to your DNS. This is to verify that you own the domain. If you migrate from an existing prod-app, make sure to use the advanced method.
- Firebase will create a certificate for it, once you've set up the DNS correctly. This can take up to 24h.
- in the meantime, install firebase tools:
npm i -g firebase-tools - Login to firebase:
firebase login - You need to create a
.firebasercfile. You can usefirebase initto create it with a wizard. If your app serves several sites, set up one hosting target per site. - Create a
firebase.jsonwhere the hosting is set up - Make sure your cloud run app is deployed to production and works correctly there
- Run
firebase deploy --only hostingto deploy the hosting - if your certificate from step 2 is ready, you can change the A-record of your domain to point to the firebase hosting IP. You can find it in the firebase console under hosting.
Some tips:
- if you migrate from an existing prod-app, make sure to use the advanced method in step 2, which first generates the certificate. Changes in any DNS can take time to propagate and is not immediately active for all users on the globe. If you use the simple method, which is change either CNAME or A-record, you might end up with a certificate error.
- You can have multiple domains on the same firebase project. This can be used for apps with dedicated frontend and api service and you can also add custom domains for staging
- no changes are needed in catladder. It will still deploy to cloud run, but firebase will route the traffic to it. You can still use the cloud run url to access your app.
- Still make sure to set
hostin catladder.ts of your prod environment, so that your services know the url where there are hosted from (ROOT_URL)
Pitfalls:
- firebase will provide a CDN out-of-the-box, but will make some assumptions about your app. All cookies will be stripped, with the exception of
__session.
Using google load balancer
Google load balancer is a more expensive option, but it's more flexible and can be used for more complex use cases.
we suggest you use DNS authorization for leasing the ssl certificate.
You will need to create some entities on google cloud. You can name them freely, but we recommend to use something like appname-componentName for the name. E.g. myapp-www.
We also suggest to create wildcard certificates.
First let's set up the load balancer. Best use the wizard: https://console.cloud.google.com/net-services/loadbalancing/
- click on create
- chose "Application Load Balancer"
- chose "public facing"
- use global or regional. Use regional if you have legal restrictions (it isn't cheaper than a global). If in doubt: use global.
- chose "Global external application load balancer"
- click "configure"
- give name (e.g. myapp-www)
- chose "HTTPS"
- Unfortunatly the wizard doesn't support DNS authorization, which we will add later. But it also does not let us skip it here. So click on "create a new certificate", chose "Create Google-managed certificate" and type some fantasy domain (e.g. example.com) and name like
delete-me - For backend configuration, click on "create a backend service", give it some name. Chose "serverless network endpoint group", in Backends, search your cloud run service and chose it. You can further specify CDN settings there, but you can do that later as well, so leave it as is.
- Review everything and create it
Next, we will create a DNS authorization and a certificate.
👉 Pro tip, instead of always specifying the project (with --project=my-google-cloud-project), you can set the default project: gcloud config set project my-google-cloud-project
In the following, replace "yourdomain.com" with your actual domain.
gcloud certificate-manager dns-authorizations create myapp-www --domain="yourdomain.com"gcloud certificate-manager certificates create myapp-www --domains="yourdomain.com,*.yourdomain.com" --dns-authorizations=myapp-wwwgcloud certificate-manager maps create myapp-wwwgcloud certificate-manager maps entries create myapp-www --map="myapp-www" --certificates="myapp-www" --hostname="yourdomain.com"gcloud certificate-manager maps entries create myapp-www-wildcard --map="myapp-www" --certificates="myapp-www" --hostname="*.yourdomain.com"- Now we can switch from our fake domain to the real one.
- The load balancer wizard did set up a
target-https-proxyfor us. Verify this: gcloud compute target-https-proxies list- You should see something like this:
NAME SSL_CERTIFICATES URL_MAP REGION CERTIFICATE_MAP
myapp-www-target-proxy delete-me delete-me
gcloud compute target-https-proxies update myapp-www-target-proxy --certificate-map="myapp-www" --global- Remove the old
delete-mecertificate form the proxy:gcloud compute target-https-proxies update myapp-www-target-proxy --certificate-map="myapp-www" --global - Delete the old
delete-me(classic) certificate:gcloud compute ssl-certificates delete delete-me --global
Now, we need to setup the DNS.
gcloud certificate-manager dns-authorizations describe myapp-wwwshould return you information about what to add to the DNS:
createTime: '2025-08-11T10:09:48.402782386Z'
dnsResourceRecord:
data: 2e759fc3-61ed-4422-9dc5-217575471c9b.11.authorize.certificatemanager.goog.
name: _acme-challenge.yourdomain.com.
type: CNAME
domain: yourdomain.com
name: projects/my-google-cloud-project/locations/global/dnsAuthorizations/myapp-www
type: FIXED_RECORD
updateTime: '2025-08-11T10:09:49.169018244Z'
- Add the CNAME record to your DNS. Hostname
_acme-challenge.yourdomain.com.and content is the string indata(2e759fc3-61ed-4422-9dc5-217575471c9b.11.authorize.certificatemanager.goog) - Wait for the certificate to be created. This can take some minutes or hours, but is usually much faster than e.g. firebase hosting. You can check the progress here: https://console.cloud.google.com/security/ccm/list/certificates
- Once the certificate is created, you can change the A-record of your domain to point to the load balancer IP. You can find it in the load balancer console under "IP addresses".
Monitoring & Performance
Traces and logs
Panter devs: the internal Notion pages on logging and tracing cover these topics in much more depth, with further examples.
Logs are taken automatically by cloud run, so simply log to stdout and stderr is the simplest approach. However, you may want to group logs by request, or add additional context to logs. This can be achieved with structured logging and using @google-cloud/logging-winston.
However, there are certain pitfalls to be aware of, so best use this example
import winston, { format } from "winston";
import { LoggingWinston, express } from "@google-cloud/logging-winston";
const isRunningOnGoogleCloud =
Boolean(process.env.K_SERVICE) || Boolean(process.env.CLOUD_RUN_JOB);
const googleCloudLogger = new LoggingWinston({
redirectToStdout: true,
useMessageField: false, // <-- bug https://github.com/googleapis/nodejs-logging-winston/issues/704
});
const myFormat = format.printf(({ level, message, timestamp, ...rest }) => {
return `${timestamp} [${level}] ${message} ${
rest ? JSON.stringify(rest) : ""
}`;
});
type Config = {
level?: string;
};
type Middleware = ReturnType<typeof express.makeMiddleware>;
export default class LoggerFactory {
winstonLogger: winston.Logger;
constructor(config: Config) {
this.winstonLogger = winston.createLogger({
level: config.level || "info",
transports: isRunningOnGoogleCloud
? [googleCloudLogger]
: [
new winston.transports.Console({
format: format.combine(
format.timestamp(),
format.colorize(),
myFormat,
),
}),
],
});
}
public getLogger() {
return this.winstonLogger;
}
public async makeExpressMiddleware(): Promise<Middleware> {
// the middleware is a bit stupid and crashes when run locally https://github.com/googleapis/nodejs-logging-winston/issues/813
if (isRunningOnGoogleCloud) {
return await express.makeMiddleware(this.winstonLogger);
} else {
return (req, res, next) => {
(req as any).log = this.winstonLogger;
next();
};
}
}
}
You can then use the makeExpressMiddleware so that each req has a log object which is an instance of a winston logger. All those logs will have the same trace as the initial request, so you can group them in google cloud logs
Tracing
Tracing allows you to see how much time is spent in which part of your app. This is useful to identify bottlenecks and to see how long a request takes.
When traces are enabled some of your requests contain traces and can be either looked at in the google cloud console or the traces explorer.
To enable traces and instrumentation in Node.js apps with GraphQL and express you can use this example:
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import {
SimpleSpanProcessor,
ConsoleSpanExporter,
BatchSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { GraphQLInstrumentation } from "@opentelemetry/instrumentation-graphql";
import { TraceExporter } from "@google-cloud/opentelemetry-cloud-trace-exporter";
const provider = new NodeTracerProvider();
// Configure a test exporter to print all traces to the console
if (process.env.TRACES_CONSOLE === "true") {
const consoleExporter = new ConsoleSpanExporter();
provider.addSpanProcessor(new SimpleSpanProcessor(consoleExporter));
}
const exporter = new TraceExporter();
// Configure the span processor to batch and send spans to the exporter
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
// Register the provider to begin tracing
provider.register();
// Register server-related instrumentation
registerInstrumentations({
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
new GraphQLInstrumentation(),
],
});
IMPORTANT:
there are some pitfalls to be aware of:
- Make sure this file is the very first that you import in your server, even before importing any other library
- bundling the server using esbuild or webpack can lead to issues. Best make sure to not bundle dependencies. In esbuild this can be done using
--packages=external. Full example:esbuild ./src/index.ts --bundle --outfile=./dist/index.js --platform=node --keep-names --packages=external - if you still have problems, enable diagnostics:
diag.setLogger(new DiagConsoleLogger(), { logLevel: DiagLogLevel.ALL });(you can import diag and the stuff from one of the opentelemetry packages)
Migration from Kubernetes to Cloud Run
Preparations:
- make sure you have access to the DNS of your domain (see Custom domains chapter)
- Check the CLOUD READY principles and make sure your app is cloud ready
- Check whether your app use background workers, cronjobs and one-time jobs to understand the cost structure
- Be aware of the costs and check whether existing hosting costs are higher or lower than your estimations
Steps:
- Change the catladder.ts config to use
google-cloud-runas deploy type. Don't forget to alter all jobs as well. - run
catladder project setup - migrate the
POSTGRESQL_PASSWORDsecret to theDB_PASSWORDsecret. - Push and make sure it works on your review-branch. Test it thoroughly
- Merge and create a release. Wait before you deploy to production.
- If you have cronjobs and background workers, you need to manually remove them from Kubernetes because once you deploy the app to production, the new cronjobs and workers will start to operate as well.
- Deploy it to production. Make sure it works correctly by accessing it via the cloud run url. If you have custom domains, you can already set them up, but don't change the DNS yet.
- Change the DNS to point the public prod domain to the custom domain (firebase) or load balancer.
- Once DNS is propagated (can take up to 24h), you can safely delete the old Kubernetes namespace as no traffic should land there any more.
Considerations
min instances and scaling behaviour
Cloud run scales down to 0 when your app is not used. This is cost-efficient, but lead to "cold starts" when someone access your app after a while. A typical node app is not so fast with starting, so cold start is often too long for a user to wait.
Having 0 min instances is therefore a recommended option for all non prod-apps.
For prod-apps, consider setting minInstances to 1. Be aware of the cost implications.
Max instances
Cloud run scales up to 100 instances by default. This is a lot and can lead to high costs. Consider setting a max instance limit.
Bursts can happen if you are targeted with DDoS-attacks.
Cronjobs and one time jobs
Cronjobs and one time jobs (e.g. migrations) are supported by catladder and cloud-run. They are implemented by using a cloud run job and setting up a cloud runner, see Jobs and migrations chapter.
If you have a cronjob that runs more often then every 10 min, you might consider using a background worker instead as this is more ccost-efficient
Background workers
Background workers are supported by catladder and cloud-run, but there are comparably expensive, since you need to always allocate CPU and memory for them.
Check the example cloud-run-meteor-with-worker to see how to set it up.
Consider migrating away from background workers to either cronjobs are asynchronous tasks using Cloud Tasks.
MongoDb
We supported MongoDB in Kubernetes by using a helm chart. This is not supported in cloud run. You can use a managed MongoDB service, e.g. MongoDB atlas.
Health Check
HTTP1 and TCP probes for health checks are supported and can be configured in deploy.service.healthCheck.
Cloud Run's Default Health Check
When deploy.service.healthCheck is not set, Cloud Run's default health check is used. It is a TCP startup probe that checks if the container is listening on the port. No liveness probe is set.
{
components: {
www: {
deploy: {
type: "google-cloudrun",
// ...
service: {
// healthCheck is not set
},
},
},
},
}
Enabling Health Check
To enable default health checks, set healthCheck to true:
{
components: {
www: {
deploy: {
type: "google-cloudrun",
// ...
service: {
healthCheck: true,
},
},
},
},
}
Default values for startup and liveness probes can be found in defaultStartupProbe and defaultLivenessProbe.
Custom Liveness and Startup Probes
When you need custom health checks, you can use the livenessProbe and startupProbe options:
{
components: {
www: {
deploy: {
type: "google-cloudrun",
// ...
service: {
healthCheck: {
livenessProbe: {
type: "http1",
failureThreshold: 3,
initialDelaySeconds: 0,
path: "/liveness/health",
periodSeconds: 60,
timeoutSeconds: 30,
},
startupProbe: {
type: "http1",
failureThreshold: 3,
initialDelaySeconds: 0,
path: "/startup/health",
periodSeconds: 10,
timeoutSeconds: 5,
},
},
},
},
},
},
}