DEV Community

Aaroophan Varatharajan
Aaroophan Varatharajan

Posted on Originally published at aaroophan.medium.com on

Complete Staging Backend Release to Cloud Run Using a Lightsail Deployment VM

A practical Cloud Run deployment workflow using Lightsail, Cloud SQL, Prisma, Artifact Registry, and Firebase staging infrastructure

A technical guide to deploying a staging backend to Google Cloud Run from a Lightsail VM, covering Cloud SQL connectivity, Prisma migrations, environment-specific DATABASE_URLs, Docker images, Artifact Registry, Cloud Storage, and immutable Cloud Run revisions.
A technical guide to deploying a staging backend to Google Cloud Run from a Lightsail VM, covering Cloud SQL connectivity, Prisma migrations, environment-specific DATABASE_URLs, Docker images, Artifact Registry, Cloud Storage, and immutable Cloud Run revisions.

A staging backend sounds almost insultingly simple.

Copy the development setup. Create a staging database. Change a few environment variables. Build the container. Deploy it.

✨Done✨

That intuition works right up until the same DATABASE_URL needs to mean two different things depending on where the command is running.

That is where staging stops being “dev, but elsewhere” and starts becoming an environment of its own.


Copying dev is the obvious starting point

Suppose an existing deployment VM already has a structure like:

backend/
└── dev/
    └── application-backend/
Enter fullscreen mode Exit fullscreen mode

The reasonable first move is not to invent another deployment system.

Create staging beside dev:

cd /home/ubuntu/backend
mkdir -p stg
cp -a dev/. stg/
Enter fullscreen mode Exit fullscreen mode

Now we have:

backend/
├── dev/
└── stg/
    └── application-backend/
Enter fullscreen mode Exit fullscreen mode

This is deliberately boring.

The Dockerfile already works. The repository is already there. The deployment VM already has Docker, Git, Node.js, Prisma dependencies, and access to the container registry.

Reusing that machinery reduces the number of new things staging can break.

But copying the directory only duplicates the deployment shape.

It does not create an isolated environment.

For that, staging needs its own state.


The database is where staging becomes real

A new low-spec PostgreSQL Cloud SQL instance was created from the Google Cloud Console.

The important decisions were not glamorous:

  • Same GCP project family
  • Same region as the backend
  • Same PostgreSQL major version as the existing environment
  • Single-zone availability
  • Low-cost staging-sized compute
  • Separate staging database
  • Separate credentials

The staging database was then restored from an existing database backup so that the application had realistic structure and data to work with.

At this point, the tempting configuration looks like this:

# Deploying to GCP Cloud Run - STG
NODE_ENV="DEV"
DATABASE_URL="postgresql://...@localhost:5432/app_stg?host=/cloudsql/<instance-connection-name>"
GCP_PROJECT_ID="<project-id>"
GCP_STORAGE_BUCKET="<staging-bucket>"
FRONTEND_URL="<staging-frontend-url>"
Enter fullscreen mode Exit fullscreen mode

That database URL is perfectly reasonable.

For Cloud Run.

Then we try to run migrations from the Lightsail VM.

And suddenly localhost starts lying to us.


One database, two network contexts

This was the important constraint.

Cloud Run and the Lightsail deployment VM do not reach Cloud SQL the same way.

When the backend runs inside Cloud Run, the service can be connected to a Cloud SQL instance and access its Unix socket through:

/cloudsql/<INSTANCE_CONNECTION_NAME>
Enter fullscreen mode Exit fullscreen mode

So this kind of URL makes sense inside Cloud Run:

DATABASE_URL="postgresql://<user>:<password>@localhost:5432/app_stg?host=/cloudsql/<instance-connection-name>"
Enter fullscreen mode Exit fullscreen mode

But npx prisma migrate deploy is not running inside Cloud Run.

It is running here: Lightsail VM

There is no Cloud Run-mounted /cloudsql/... socket there.

The same URL that is correct for the deployed application is wrong for the deployment machine.

That is the trap.

The database has not changed.

The network boundary has.

So staging ended up needing two configuration blocks.

One for the deployed application:

# Deploying to GCP Cloud Run - STG
NODE_ENV="DEV"
DATABASE_URL="postgresql://<user>:<encoded-password>@localhost:5432/app_stg?host=/cloudsql/<instance-connection-name>"
...
Enter fullscreen mode Exit fullscreen mode

And another for commands executed directly from the deployment VM:

# Running locally - STG
# NODE_ENV="DEV"
# DATABASE_URL="postgresql://<user>:<encoded-password>@<cloud-sql-public-ip>:5432/app_stg?schema=public"
# ...
Enter fullscreen mode Exit fullscreen mode

Only one is active at a time.

That sounds slightly cursed until the mental model clicks:

One database, two network contexts

While runtime is:

One database, two network contexts

Same database.

Different caller.

Different connection path.


Public IP access is not just “make the URL public”

Using the Cloud SQL public IP introduces another boundary.

The deployment VM has to be allowed through it.

So the VM’s public IP was added in:

  • Google Cloud Console → Cloud SQL → Staging instance → Connections → Networking → Authorized networks

As a single-host network: /32

Now the VM can reach PostgreSQL directly.

Before running anything destructive-looking, the active configuration can be checked:

grep -n "^[^#].*DATABASE_URL" .env.dev
Enter fullscreen mode Exit fullscreen mode

For the migration step, there should be exactly one active DATABASE_URL, and it should contain:

@<cloud-sql-public-ip>:5432/<staging-database>
Enter fullscreen mode Exit fullscreen mode

That tiny check matters.

There are few feelings more cursed than a successful migration against the wrong database.


Migration is part of the release, not the container

With the public-IP staging configuration active, the target release branch can be checked out:

git fetch origin
git checkout -B <release-branch> origin/<release-branch>
git status
Enter fullscreen mode Exit fullscreen mode

Then:

npx prisma migrate deploy
Enter fullscreen mode Exit fullscreen mode

Followed by:

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

These commands have different responsibilities.

migrate deploy applies the migrations already committed to the repository.

It does not invent new migrations.

It does not reset the database.

And it does not regenerate Prisma Client artifacts.

That is why prisma generate follows it.

The ordering becomes:

Public IP access is not just “make the URL public”

Only after those succeed should the environment file be switched back.

The public-IP configuration gets commented again.

The Cloud Run configuration becomes active:

DATABASE_URL="postgresql://<user>:<encoded-password>@localhost:5432/app_stg?host=/cloudsql/<instance-connection-name>"
Enter fullscreen mode Exit fullscreen mode

Then check again:

grep -n "^[^#].*DATABASE_URL" .env.dev
Enter fullscreen mode Exit fullscreen mode

This time the active URL should contain:

host=/cloudsql/<instance-connection-name>
Enter fullscreen mode Exit fullscreen mode

This switching looks awkward at first.

But the awkwardness is telling us something useful.

Deployment-time connectivity and runtime connectivity are different responsibilities.


Now the container becomes the release artifact

Once the database is ready and the runtime configuration is active again, the application can be packaged.

First identify exactly what is being released:

git log --oneline
Enter fullscreen mode Exit fullscreen mode

Take the short commit hash and combine it with a release version and environment name.

Then build:

docker build -t application-backend:latest .
Enter fullscreen mode Exit fullscreen mode

Tag:

docker tag application-backend:latest \
  <region>-docker.pkg.dev/<project>/<repository>/application-backend:<commit>-v<version>-stg
Enter fullscreen mode Exit fullscreen mode

Push:

docker push \
  <region>-docker.pkg.dev/<project>/<repository>/application-backend:<commit>-v<version>-stg
Enter fullscreen mode Exit fullscreen mode

The tag is doing more work than it appears to.

Something like:

<commit>-v2.xx-stg
Enter fullscreen mode Exit fullscreen mode

answers three useful questions:

  • Which source?
  • Which release?
  • Which environment?

The image is no longer “whatever was on the VM.”

It is now a named deployment artifact in Artifact Registry.

That boundary is important because Cloud Run is not deploying the working directory.

It is deploying the image.


Cloud Run does not edit the old deployment

The final step happens in the Google Cloud Console:

  • Cloud Run → Services → → Edit & deploy new revision

Then:

  • Edit container → Container image → Select the newly pushed image → Deploy

The wording matters: new revision.

Cloud Run does not quietly mutate the previous revision.

A changed image or changed service configuration produces another revision.

That gives the release a clean progression:

Cloud Run does not edit the old deployment

Each stage points to something concrete.

If the new revision misbehaves, the previous revision still exists.

The deployment has history instead of amnesia.


A staging backend is more than compute

The backend was now running.

Then another constraint became visible.

The staging database had been restored from production data, but staging had its own brand-new storage bucket.

The database knew about existing logos and artifacts.

The staging bucket knew absolutely nothing about them.

The application asked for an artifact.

The database said, “Sure, it exists.”

The empty bucket said, “Never heard of it.”

Nothing was technically lying.

They simply represented different copies of state.

Temporarily pointing staging at the existing artifact bucket made the old assets visible, but that is not the clean boundary we want long term. A staging environment should not casually share writable production storage.

The better direction is to migrate or copy the required artifacts into the staging bucket while preserving object paths, then return staging to:

GCP_STORAGE_BUCKET="<staging-bucket>"
Enter fullscreen mode Exit fullscreen mode

This revealed a broader point about environment cloning.

Copying a database backup does not clone an application environment.

The database may contain references to:

  • object storage
  • external URLs
  • generated files
  • identity providers
  • frontend origins
  • secrets
  • third-party services

State has dependencies too.


The release flow that finally makes sense

Once all those constraints are visible, the staging release stops looking like a random collection of commands.

It has an order:

The release flow that finally makes sense

The uncomfortable part is that several configurations are all “correct.”

They are just correct in different contexts.

The public-IP database URL is correct from the deployment VM.

The Unix-socket database URL is correct from Cloud Run.

The source tree is correct for building.

The container image is correct for deployment.

The database backup is correct for relational state.

The storage bucket is responsible for artifact state.

The staging frontend URL is responsible for the staging web boundary.

The Origami Software Engineer is responsible for the handoffs.

Trying to make one of those things own everything is where the system starts feeling cursed.

The better model is to let each boundary own exactly what it is supposed to own.

A staging release is not one deployment command.

It is a controlled handoff between environments, state, network boundaries, and immutable artifacts.

Once that becomes visible, the sequence stops looking annoyingly ceremonial.

It starts looking protective.

That’s not failure.

That’s evolution.


The “I liked this” Starter Pack:

Don’t let your fingers get lazy now.

  • Like : It tells me this was worth writing.
  • A Comment: Tell me your thoughts, your favorite snack, or a better title for this blog.
  • Boost it: Especially with that one developer who definitely needs this.

Thanks for being here. It genuinely helps more than you know!

— Aaroophan Varatharajan

Find me elsewhere:

Top comments (0)