Import-Aware CI/CD for Firebase Cloud Functions: Deploy Only What Changed
If you have a monorepo with many Cloud Functions, setting up CI/CD can feel like something to avoid — one wrong change and you're redeploying everything.
In this guide I'll show a better approach: selective deployment using static import analysis, without any complex monorepo tooling like Bazel or Nx.
The Problem
If you have a Firebase project with 40+ Cloud Functions, a naive CI/CD pipeline deploys all of them on every push. A 30-second code change to one function kicks off a 15-minute deployment of everything. It's slow, risky, and wastes money on unnecessary Cloud Build minutes.
We needed a pipeline that could answer one question: which functions are actually affected by this change?
The Goal
- On a pull request: post a comment showing exactly which functions will deploy
- On merge: deploy only those functions
- If a shared utility changes: correctly identify every function that depends on it, transitively
The Approach: Static Import Graph Analysis
The key insight is that Firebase Cloud Functions are just Node.js modules. Each function exports a handler from a route file, and those route files import shared utilities, repositories, and config. If you change a shared file, every function that transitively imports it is affected.
We used dependency-cruiser to build a static import graph from all route files and traverse it in reverse — from a changed file back to every function that depends on it.
Why Not Cloud Build + Cloud Deploy?
The short answer: Firebase CLI is your IaC engine, and replacing it is expensive.
Firebase CLI doesn't just push code — it translates your Node.js function definitions into fully-configured Cloud Run services and Eventarc triggers, handling IAM bindings, VPC connectors, memory and concurrency settings, and service configuration. It reads your firebase.json and wires up GCP infrastructure correctly. It's an IaC layer, not just a deployment CLI.
2nd gen Cloud Functions do run on Cloud Run under the hood, so Cloud Deploy can technically target them as Cloud Run services — giving you rollback and canary deployments that firebase deploy lacks. But to get there, you'd have to rebuild everything Firebase CLI provides for free:
- Write your own Dockerfiles and Cloud Build steps
- Manually configure each Cloud Run service (memory, concurrency, env, min/max instances)
- Set up Eventarc triggers for event-driven functions
- Manage IAM yourself, with no Firebase-managed defaults
That's a lot of IaC to own just to gain rollback and canary support. And you'd still need the same import graph analysis to know which services to deploy.
The import-aware pipeline is the right middle ground: Firebase CLI stays as the IaC engine — correctly parsing your code and wiring up GCP infrastructure — while the detection layer restricts each deploy to only the functions that actually changed.
The honest trade-off: you give up rollback and progressive rollout. If those become requirements, managing functions as Cloud Run services directly is worth revisiting. For selective deployment without canary needs, it's not the right trade.
Step 1: The Function Map
Create .github/function-map.json — a mapping from route file path to Firebase function name:
{
"functions/src/modules/media/routes/get-content.route.ts": "app-get-content",
"functions/src/modules/media/routes/search.route.ts": "app-search",
"functions/src/modules/admin/routes/content-management.route.ts": "app-admin-content",
"functions/src/modules/admin/routes/live-event.route.ts": "app-admin-live-event"
}
Every entry is a leaf — a file that exports a Cloud Function. This is the source of truth for the pipeline.
Step 2: The Detection Script
.github/scripts/get-changed-functions.js takes a list of changed files via CHANGED_FILES env var and returns the Firebase deploy targets:
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.resolve(__dirname, '../..');
const functionMap = JSON.parse(
fs.readFileSync(path.join(__dirname, '../function-map.json'), 'utf8')
);
const changedFiles = (process.env.CHANGED_FILES || '')
.split('\n').map(f => f.trim()).filter(Boolean);
if (changedFiles.length === 0) {
console.log('NONE');
process.exit(0);
}
const routeFiles = Object.keys(functionMap);
// Build full import graph with dependency-cruiser
const depcruiseBin = path.join(REPO_ROOT, 'functions/node_modules/.bin/depcruise');
const output = execSync(
`"${depcruiseBin}" --no-config --include-only "^functions/src" --output-type json ${routeFiles.join(' ')}`,
{ cwd: REPO_ROOT, maxBuffer: 10 * 1024 * 1024 }
).toString();
const allModules = JSON.parse(output).modules;
// Build adjacency list: source → direct dependencies
const directDeps = {};
for (const module of allModules) {
directDeps[module.source] = module.dependencies
.filter(d => !d.couldNotResolve)
.map(d => d.resolved);
}
// For each route file, compute full transitive dependency set (BFS)
const reverseDeps = {};
for (const routeFile of routeFiles) {
const visited = new Set();
const queue = [routeFile];
while (queue.length > 0) {
const current = queue.shift();
if (visited.has(current)) continue;
visited.add(current);
for (const dep of (directDeps[current] || [])) {
if (!visited.has(dep)) queue.push(dep);
}
}
for (const dep of visited) {
if (dep === routeFile) continue;
if (!reverseDeps[dep]) reverseDeps[dep] = new Set();
reverseDeps[dep].add(routeFile);
}
}
// Find all affected functions
const affected = new Set();
for (const changedFile of changedFiles) {
if (functionMap[changedFile]) affected.add(functionMap[changedFile]);
if (reverseDeps[changedFile]) {
for (const routeFile of reverseDeps[changedFile]) {
affected.add(functionMap[routeFile]);
}
}
}
if (affected.size === 0) {
console.log('NONE');
} else {
console.log([...affected].map(n => `functions:${n}`).join(','));
}
If dependency-cruiser fails for any reason, it falls back to deploying all functions — safe by default.
Step 3: The GitHub Actions Workflow
Two jobs: preview on PRs, deploy on merge.
name: Deploy Cloud Functions
on:
pull_request:
branches: [staging, master]
paths:
- 'functions/src/**'
- 'functions/package.json'
- 'firebase.json'
push:
branches: [staging, master]
paths:
- 'functions/src/**'
- 'functions/package.json'
- 'firebase.json'
jobs:
preview:
name: Deployment Preview
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: npm ci
working-directory: functions
- name: Detect affected functions
id: preview
run: |
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
DEPLOY=$(CHANGED_FILES="$CHANGED" node .github/scripts/get-changed-functions.js)
echo "deploy_target=$DEPLOY" >> $GITHUB_OUTPUT
deploy:
name: Deploy to ${{ github.ref_name == 'master' && 'Production' || 'Staging' }}
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect affected functions
id: changed
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD)
DEPLOY=$(CHANGED_FILES="$CHANGED" node .github/scripts/get-changed-functions.js)
echo "deploy_target=$DEPLOY" >> $GITHUB_OUTPUT
- name: Install Firebase CLI
run: npm install -g firebase-tools
- name: Deploy changed functions
if: steps.changed.outputs.deploy_target != 'NONE'
run: |
firebase deploy --only ${{ steps.changed.outputs.deploy_target }} \
--project ${{ vars.FIREBASE_PROJECT }} --non-interactive
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/sa.json
What We Hit in Production
1. contents: read permission missing
When you set any permission explicitly, all others default to none. So actions/checkout silently failed because the token had no contents permission.
Fix: Always pair pull-requests: write with contents: read.
2. firebase: command not found
The Ubuntu runner doesn't have the Firebase CLI pre-installed.
Fix: Add npm install -g firebase-tools before the deploy step.
3. Firebase Tools billing API false positive
After upgrading to Node 24, deployments started failing with a 403 on the Cloud Billing API — even though billing was enabled. This is a known Firebase CLI bug introduced in v13.15.4 where the CLI makes an unnecessary billing API check.
Fix: Pin or upgrade firebase-tools past the affected range.
4. IAM permission for 2nd gen Cloud Functions
Caller is missing permission 'iam.serviceaccounts.actAs' on service account
<project-number>-compute@developer.gserviceaccount.com
For 2nd gen Cloud Functions, Firebase uses Cloud Build internally. The Cloud Build service account needs roles/iam.serviceAccountUser on the compute default service account.
Fix:
gcloud iam service-accounts add-iam-policy-binding \
<project-number>-compute@developer.gserviceaccount.com \
--member="serviceAccount:<project-number>@cloudbuild.gserviceaccount.com" \
--role="roles/iam.serviceAccountUser" \
--project=<your-project>
5. Container health check failing — OOM on Node 24
Node 24 has a higher baseline memory footprint than Node 18. The root cause was a shared configurations.ts file that eagerly initialized three heavy SDK clients at module load time:
// These ran on EVERY function import, even functions that never use them
const spanner = new Spanner({ projectId });
const pgPool = new Pool({ host, database, user, password, port });
const transcoderClient = new TranscoderServiceClient();
Fix: Lazy initialization using ES getter syntax:
let _pgPool: Pool | undefined;
const getPgPool = () => {
if (!_pgPool) _pgPool = new Pool({ ... });
return _pgPool;
};
export default () => ({
database: {
postgres: { get connection() { return getPgPool(); } },
},
});
A function that only needs VPC config now pays zero cost for Spanner or Postgres at startup.
6. Skipping CI on shared dependency changes
Changing a shared file triggers a redeploy of every function that imports it. For infrastructure-only changes, include [skip ci] in the commit message and squash merge the PR.
The Result
The PR comment now shows exactly which functions will deploy:
☁️ Cloud Functions Deployment Preview
🧪 Staging — merging this PR will deploy:
- app-admin-live-event
Deploy times dropped from ~15 minutes (all functions) to under 3 minutes for a typical single-function change.
Key Takeaways
- dependency-cruiser gives you a precise, statically-derived import graph — no guessing, no glob patterns
- 2nd gen Cloud Functions have more IAM setup requirements than 1st gen
- Eager SDK initialization in shared config modules is a silent memory tax
- ES getters are the cleanest way to make config lazy without changing how callers access it
- Use
[skip ci]+ squash merge to avoid triggering mass redeployments on infrastructure changes