Workaround for Apps Script UrlFetchApp “Bandwidth quota exceeded” errors using Cloud Run

We are currently experiencing intermittent UrlFetchApp errors in Apps Script:

Exception: Bandwidth quota exceeded: <url>. Try reducing the rate of data transfer.

This started suddenly around April 2026 and matches the public issue reported here:

https://issuetracker.google.com/issues/505172128

The problem is that the error happens even with low usage, small payloads, and previously stable scripts. In our case, this makes Apps Script web apps unreliable because any server-side API call using UrlFetchApp.fetch() can randomly fail.

This article shows the workaround I used: move the API call to Cloud Run and call Cloud Run directly from the Apps Script frontend using client-side fetch().

We will replace this API call:

const response = Sheets.Spreadsheets.Values.get(ssId, sheetName);

With:

Apps Script Web App
↓ client-side fetch()
Cloud Run service
↓ Google Sheets API
Spreadsheet data

Apps Script only serves the HTML page. The browser calls Cloud Run. Cloud Run reads the spreadsheet using the Sheets API and returns JSON.

1. Create the Cloud Run service

Create a local folder with:

package.json
server.js

package.json

{
"name": "sheets-api-proxy",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.3",
"googleapis": "^144.0.0"
}
}

server.js

import express from "express";
import { google } from "googleapis";const app = express();app.use(express.json());const PORT = process.env.PORT || 8080;
const SPREADSHEET_ID = process.env.SPREADSHEET_ID;/**
 * Adds CORS headers for Apps Script web app frontend calls.
 *
 * @param {import("express").Request} req The Express request.
 * @param {import("express").Response} res The Express response.
 * @param {import("express").NextFunction} next The next Express middleware.
 * @returns {void}
 */
function corsMiddleware(req, res, next) {
  const origin = req.headers.origin || "";  const isAllowedOrigin =
    origin === "https://script.google.com" ||
    origin.endsWith(".googleusercontent.com");  if (isAllowedOrigin) {
    res.setHeader("Access-Control-Allow-Origin", origin);
  }  res.setHeader("Vary", "Origin");
  res.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");  if (req.method === "OPTIONS") {
    res.status(204).send("");
    return;
  }  next();
}app.use(corsMiddleware);/**
 * Creates an authenticated Google Sheets API client.
 *
 * @returns {Promise<import("googleapis").sheets_v4.Sheets>} The authenticated Sheets API client.
 */
async function getSheetsClient() {
  const auth = new google.auth.GoogleAuth({
    scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"]
  });  const authClient = await auth.getClient();  return google.sheets({
    version: "v4",
    auth: authClient
  });
}/**
 * Reads values from a Google Sheet range.
 *
 * @param {string} range The A1 notation range.
 * @returns {Promise<Array<Array<string|number|boolean>>>} The sheet values.
 */
async function readSheetValues(range) {
  const sheets = await getSheetsClient();  const response = await sheets.spreadsheets.values.get({
    spreadsheetId: SPREADSHEET_ID,
    range
  });  return response.data.values || [];
}/**
 * Converts a 2D array into objects using the first row as headers.
 *
 * @param {Array<Array<string|number|boolean>>} values The sheet values.
 * @returns {Array<Object>} The rows as objects.
 */
function valuesToObjects(values) {
  if (!values.length) {
    return [];
  }  const [headers, ...rows] = values;  return rows.map(row => {
    return headers.reduce((obj, header, index) => {
      obj[String(header)] = row[index] ?? "";
      return obj;
    }, {});
  });
}app.get("/api/sheet", async (req, res) => {
  try {
    if (!SPREADSHEET_ID) {
      res.status(500).json({
        error: "Missing SPREADSHEET_ID environment variable"
      });
      return;
    }    const range = req.query.range || "Sheet1!A1:Z";
    const values = await readSheetValues(range);
    const rows = valuesToObjects(values);    res.json({
      range,
      rowCount: rows.length,
      rows
    });
  } catch (error) {
    console.error("SHEETS_API_READ_FAILED", {
      message: error.message,
      stack: error.stack
    });    res.status(500).json({
      error: "Failed to read spreadsheet data",
      message: error.message
    });
  }
});
app.listen(PORT, () => {
  console.log(`Sheets proxy listening on port ${PORT}`);
}

2. Deploy to Cloud Run

From VS Code, login to Google Cloud, link the porject (has to be linked to a billing account in order to deploy on Cloud Run), and set the region.

gcloud auth login
gcloud config set project YOUR_PROJECT_ID
gcloud config set run/region europe-west1
Deploy:
gcloud run deploy sheets-api-proxy `
  --source . `
  --region europe-west1 `
  --allow-unauthenticated `
  --set-env-vars="SPREADSHEET_ID=YOUR_SPREADSHEET_ID"

After deployment, Cloud Run gives you a URL like:

https://sheets-api-proxy-xxxx.europe-west1.run.app

3. Fix the common IAM issue

If deployment fails with something like:

Build failed because the default service account is missing required IAM permissions

check which service account is used:

gcloud builds get-default-service-account

Then grant the required role:

gcloud projects add-iam-policy-binding YOUR_PROJECT_ID `
  --member="serviceAccount:YOUR_SERVICE_ACCOUNT" `
  --role="roles/run.builder"

4. Share the Google Sheet with Cloud Run

Cloud Run does not run as your Gmail account. It runs as a service account. Find the runtime service account:

gcloud run services describe sheets-api-proxy `
  --region europe-west1 `
  --format="value(spec.template.spec.serviceAccountName)"

If the result is empty, it is probably using the Compute Engine default service account:

PROJECT_NUMBER-compute@developer.gserviceaccount.com

Share the Google Sheet with that email and give it Viewer access.

5. Create the Apps Script web app

In Apps Script, create:

/**
 * Serves the web app HTML.
 *
 * @param {GoogleAppsScript.Events.DoGet} e The GET event object.
 * @returns {GoogleAppsScript.HTML.HtmlOutput} The HTML output.
 */
function doGet(e) {
  return HtmlService
    .createHtmlOutputFromFile("Index")
    .setTitle("Cloud Run Sheets Reader");
}

And Index.html

<!DOCTYPE html>
<html>
<head>
  <base target="_top">

  <style>
    body {
      font-family: Arial, sans-serif;
      padding: 24px;
      background: #f7f7f7;
      color: #222;
    }

    button {
      padding: 10px 16px;
      border: none;
      border-radius: 8px;
      background: #1a73e8;
      color: white;
      cursor: pointer;
      font-size: 14px;
    }

    button:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }

    #status {
      margin: 16px 0;
      color: #555;
    }

    table {
      width: 100%;
      border-collapse: collapse;
      background: white;
    }

    th,
    td {
      border-bottom: 1px solid #ddd;
      padding: 10px;
      text-align: left;
    }

    th {
      background: #f1f3f4;
    }
  </style>
</head>

<body>
  <h2>Sheet data from Cloud Run</h2>

  <button id="load-btn" onclick="loadSheetData()">Load data</button>

  <div id="status"></div>
  <div id="table-container"></div>

  <script>
    const CLOUD_RUN_URL = "https://YOUR_CLOUD_RUN_URL";
    const SHEET_RANGE = "Sheet1!A1:Z";

    /**
     * Reads spreadsheet data from Cloud Run and displays it as a table.
     *
     * @returns {Promise<void>}
     */
    async function loadSheetData() {
      const button = document.getElementById("load-btn");
      const status = document.getElementById("status");
      const container = document.getElementById("table-container");

      try {
        button.disabled = true;
        status.textContent = "Loading...";
        container.innerHTML = "";

        const range = encodeURIComponent(SHEET_RANGE);
        const response = await fetch(`${CLOUD_RUN_URL}/api/sheet?range=${range}`);

        if (!response.ok) {
          throw new Error(`Cloud Run request failed with status ${response.status}`);
        }

        const data = await response.json();

        renderTable(data.rows || []);
        status.textContent = `${data.rowCount || 0} rows loaded`;
      } catch (error) {
        console.error(error);
        status.textContent = error.message;
      } finally {
        button.disabled = false;
      }
    }

    /**
     * Renders rows as an HTML table.
     *
     * @param {Array<Object>} rows The rows to render.
     * @returns {void}
     */
    function renderTable(rows) {
      const container = document.getElementById("table-container");

      if (!rows.length) {
        container.innerHTML = "<p>No data found.</p>";
        return;
      }

      const headers = Object.keys(rows[0]);

      const thead = headers
        .map(header => `<th>${escapeHtml(header)}</th>`)
        .join("");

      const tbody = rows
        .map(row => {
          const cells = headers
            .map(header => `<td>${escapeHtml(row[header])}</td>`)
            .join("");

          return `<tr>${cells}</tr>`;
        })
        .join("");

      container.innerHTML = `
        <table>
          <thead>
            <tr>${thead}</tr>
          </thead>
          <tbody>
            ${tbody}
          </tbody>
        </table>
      `;
    }

    /**
 * Escapes a value before injecting it into HTML.
 *
 * @param {*} value The value to escape.
 * @returns {string} The escaped HTML string.
 */
function escapeHtml(value) {
  return String(value ?? "")
    .replaceAll("&", "&")
    .replaceAll("<", "<")
    .replaceAll(">", ">")
    .replaceAll('"', """)
    .replaceAll("'", "'");
}
  </script>
</body>
</html>

Deploy the Apps Script project as a web app. When you click the button, the browser calls Cloud Run directly.

No UrlFetchApp is used in this call path.

6. About CORS

Apps Script web apps are often served from a googleusercontent.com origin, not only from script.google.com.

That is why the Cloud Run code allows both:

origin === "https://script.google.com" ||
origin.endsWith(".googleusercontent.com")

7. Adding authentication

The demo above uses: –allow-unauthenticated

This is fine for testing, but not for production.

CORS is not authentication. It only controls browser access. Someone can still call your Cloud Run URL from another backend, Postman, or curl.

To reproduce the usual Apps Script behavior, Apps Script can generate a short-lived signed token for the current user. The browser sends that token to Cloud Run, and Cloud Run verifies it before returning data.

Apps Script server-side:

const CLOUD_RUN_SHARED_SECRET = "CHANGE_ME_LONG_RANDOM_SECRET";

/**
 * Creates a short-lived token for Cloud Run.
 *
 * @returns {string} The signed token.
 */
function getCloudRunToken() {
  const now = Math.floor(Date.now() / 1000);

  const payload = {
    iat: now,
    exp: now + 300,
    userEmail: Session.getActiveUser().getEmail()
  };

  const payloadText = JSON.stringify(payload);
  const payloadBase64 = Utilities.base64EncodeWebSafe(payloadText);
  const signature = Utilities.computeHmacSha256Signature(payloadBase64, CLOUD_RUN_SHARED_SECRET);
  const signatureBase64 = Utilities.base64EncodeWebSafe(signature);

  return `${payloadBase64}.${signatureBase64}`;
}

Frontend:

/**
 * Gets a short-lived Cloud Run token from Apps Script.
 *
 * @returns {Promise<string>} The signed token.
 */
function getCloudRunTokenFromServer() {
  return new Promise((resolve, reject) => {
    google.script.run
      .withSuccessHandler(resolve)
      .withFailureHandler(reject)
      .getCloudRunToken();
  });
}

Then send it to Cloud Run:

const token = await getCloudRunTokenFromServer();

const response = await fetch(`${CLOUD_RUN_URL}/api/sheet?range=${range}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${token}`
  }
});

The token does transit through the client, so it must be short-lived.

For a more complete production app, Firebase Auth or Identity Platform is a better long-term option: the frontend signs in the user, sends an ID token to Cloud Run, and Cloud Run verifies it server-side.

Conclusion

This article only shows one possible workaround while this bug is happening. It probably does not solve every case, but it allowed me to save some important workflows on my side.

If you have another idea or a better workaround, I would be interested to hear it.

Apps Script Logging Best Practices: console, Logger, Cloud Logging, and a Production-Ready Logging System

Logging in Google Apps Script is easy when you are testing a small script in the editor. It becomes much more important when the project turns into a real web app used by other people.

At that stage, logs are no longer just for debugging. They help you understand what happened, where a request failed, which user was affected, and whether the same issue is happening again. That is why it is worth understanding the difference between console, Logger, and Cloud Logging, and how to build a cleaner logging system for production.

console vs Logger in Apps Script

Apps Script gives you two main ways to write logs: console and Logger.

console is the most familiar if you come from JavaScript. It is convenient for quick debugging with methods like console.log(), console.warn(), and console.error(). It is also the best option for timing code with console.time() and console.timeEnd(). (Class console)

Logger is a better fit for structured logs. Google’s documentation explicitly points out that Logger works well with structured JSON data, which is useful when logs are sent to Cloud Logging and need to be filtered later. (Class Logger)

In simple terms, console is better for quick debugging and timing. Logger is better for cleaner, searchable application logs.

The limits of logging in deployed Apps Script web apps

Basic logging works well while you are testing from the Apps Script editor. But once a web app is deployed, the built-in execution log quickly shows its limits.

Google explains that the execution log is mainly meant for development and debugging, and that those logs do not persist for very long. That makes it fine for short-term troubleshooting, but not ideal for production follow-up. (Logging)

This is usually where developers get stuck. They can write logs, but they cannot easily search across executions, track repeated failures, or build alerts. For that, the right tool is Cloud Logging.

Why Cloud Logging matters

Cloud Logging is what makes Apps Script logging feel more professional.

When your Apps Script project is linked to a standard Google Cloud project, your logs can be viewed in Google Cloud’s Logs Explorer instead of only in the Apps Script interface. That gives you much better filtering, longer-term visibility, and access to tools like Error Reporting and log-based metrics. (Google Cloud projects)

You can use a single GCP project for several Apps Script projects

This becomes especially useful for web apps. Instead of scanning plain text logs manually, you can search by severity, message, or your own custom fields such as requestId, route, or event. (Logging query language)

Another very useful feature is the temporary active user key. Google recommends using it to correlate logs to a user session without storing personal data like email addresses. (feedbackLogging)

What a production-style logging system looks like

A good logging system in Apps Script does not need to be complicated. It just needs structure.

The main idea is that every important action should be logged with context. Instead of writing random messages like "step 1" or "here", it is much better to log a clear event with fields such as:

event, requestId, route, userKey, severity, and a small context object.

That gives you logs that are easier to read and much easier to query later in Cloud Logging.

A strong approach is to use Logger for business events and console.time() for performance tracking. That gives you both structured logs and timing data without mixing everything together. (Class Logger)

A simple production-ready logging utility

Here is a practical example you can reuse in an Apps Script web app:

/**
 * Create a request-scoped logging context.
 *
 * @param {Object} options
 * @param {string} options.route
 * @param {string=} options.requestId
 * @param {Object=} options.context
 * @returns {{route: string, requestId: string, userKey: string, context: Object}}
 */
function createLogContext(options) {
  return {
    route: options.route || "unknown",
    requestId: options.requestId || Utilities.getUuid(),  // To track that one request in Cloud Logging
    userKey: Session.getTemporaryActiveUserKey(),
    context: options.context || {}
  };
}

/**
 * Write a structured application log.
 *
 * @param {string} message
 * @param {Object} details
 * @param {string} details.event
 * @param {string=} details.severity
 * @param {string} details.route
 * @param {string} details.requestId
 * @param {string} details.userKey
 * @param {Object=} details.context
 * @returns {void}
 */
function logEvent(message, details) {
  Logger.log({
    message,
    event: details.event,
    severity: details.severity || "INFO",
    route: details.route,
    requestId: details.requestId,
    userKey: details.userKey,
    context: details.context || {},
    timestamp: new Date().toISOString()
  });
}

/**
 * Write a structured error log.
 *
 * @param {Error} error
 * @param {Object} details
 * @param {string} details.route
 * @param {string} details.requestId
 * @param {string} details.userKey
 * @param {Object=} details.context
 * @returns {void}
 */
function logError(error, details) {
  Logger.log({
    message: error.message || "Unknown error",
    event: "application_error",
    severity: "ERROR",
    route: details.route,
    requestId: details.requestId,
    userKey: details.userKey,
    context: details.context || {},
    name: error.name || "Error",
    stack: error.stack || "",
    timestamp: new Date().toISOString()
  });

  console.error(error);
}

This kind of utility already makes a big difference. It gives each request a consistent identity and makes logs much easier to follow.

What to log

For a deployed Apps Script web app, the most useful logs are usually the start of a request, the main business event, the final result, the duration of slow steps, and any real error.

What you should avoid is logging huge payloads, full spreadsheet rows, or personal data. Logs should stay useful, readable, and lightweight. Google also documents limits and quotas for Cloud Logging, so smaller structured logs are usually the better choice. (Quotas and limits)

The real best practice

The best practice is not choosing between console and Logger as if one were always better.

Use console for quick debugging and timing. Use Logger for structured events. Use Cloud Logging when the project becomes important enough to need real visibility. Then, if needed, turn recurring log patterns into metrics and alerts. (Log-based metrics overview)

That is the point where logging in Apps Script becomes genuinely useful for production, especially for web apps.

Turn an Apps Script project into an API (and Test It with Postman)

I’ve been doing freelancing for 5+ years now. Every month, I’d add a new row in my follow-up spreadsheet, duplicate my invoice, and export it as PDF to send by email. Yes, manually.

In French we say: “les cordonniers sont toujours les plus mal chaussés”. (or “the cobbler’s children go barefoot” in English).

Well I’ve decided this has to stop, and I’ve been working on a custom SaaS to handle my invoices in a proper way! Backend runs on Cloud Run, the UI is a simple web app. And for the invoice document generation itself… I’m using Apps Script!

In this article, I’ll focus on the Apps Script project itself, and its deployment so that it can be called from another app or from Postman to start with.

The connection with the SaaS itself will be for another article!

Why use Apps Script as an API?

You get a lot for free!

  • Google Docs is your template engine (layout, fonts, logo… all in the editor)
  • Drive stores your invoices in the right folder
  • Apps Script can run as you and use your Drive/Docs permissions
  • You get an HTTP endpoint that your backend (or Postman) can POST JSON to

Also, my entire blog is about Apps Script, so…

So the main idea in our example is:

  1. Have a Docs template with placeholders like {{ClientName}}, {{TotalPrice}}.
  2. Send a JSON payload to a Web App URL.
  3. Apps Script copies the template, replaces the tags, and returns the document URL.

No custom PDF rendering, no extra infra.

The Apps Script “Invoice API” code

We will first create a new Apps Script project. By default, Apps Script links new project to GCP, but for the next steps, we will need to manually link our GCP project.

I also setup a very simple invoice template, with the tags I wanted.

Here is the full code.

/**
 * Web app entry point for POST requests.
 * Expects JSON payload with:
 * {
 *   "tempId": "TEMPLATE_DOC_ID" | "templateId",
 *   "folderId": "DRIVE_FOLDER_ID",
 *   "filename": "Invoice 001",
 *   "tags": {
 *     "InvoiceNumber": "Lidia",
 *     "date": "123 €",
 *     "ClientName":"",
 *   }
 * }
 *
 * @param {GoogleAppsScript.Events.DoPost} e - The POST event.
 * @returns {GoogleAppsScript.Content.TextOutput} JSON response.
 */
function doPost(e) {
  let output;

  try {
    if (!e || !e.postData || !e.postData.contents) {
      throw new Error("Missing POST body.");
    }
    const payload = JSON.parse(e.postData.contents);

    const result = createInvoiceFromTemplate_(payload);

    const response = {
      success: true,
      invoiceId: result.id,
      invoiceUrl: result.url,
      filename: result.name,
    };

    output = ContentService
      .createTextOutput(JSON.stringify(response))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (err) {
    const errorResponse = {
      success: false,
      error: String(err && err.message ? err.message : err),
    };

    output = ContentService
      .createTextOutput(JSON.stringify(errorResponse))
      .setMimeType(ContentService.MimeType.JSON);
  }

  return output;
}

/**
 * @typedef {Object} InvoiceRequestPayload
 * @property {string} [tempId] - Template document ID (legacy name).
 * @property {string} [templateId] - Template document ID (preferred name).
 * @property {string} [folderId] - Drive folder ID where the new doc should be stored.
 * @property {string} filename - Name for the new document.
 * @property {Object.<string, string|number|boolean|null>} tags - Map of placeholders to values.
 */

/**
 * @typedef {Object} InvoiceCreationResult
 * @property {string} id - Created document ID.
 * @property {string} url - Created document URL.
 * @property {string} name - Created document name.
 */

/**
 * Creates an invoice document from a template and replaces placeholders.
 *
 * @param {InvoiceRequestPayload} payload - Data describing the invoice to create.
 * @returns {InvoiceCreationResult} Information about the created document.
 */
function createInvoiceFromTemplate_(payload) {
  if (!payload) {
    throw new Error("Payload is required.");
  }

  const templateId = payload.templateId || payload.tempId;
  if (!templateId) {
    throw new Error("templateId (or tempId) is required.");
  }

  if (!payload.filename) {
    throw new Error("filename is required.");
  }


  let templateFile;
  try {
    templateFile = DriveApp.getFileById(templateId)
  } catch (err) {
    throw new Error("Template file not found or inaccessible.");
  }

  /** @type {GoogleAppsScript.Drive.Folder} */
  let targetFolder;

  if (payload.folderId) {
    try {
      targetFolder = DriveApp.getFolderById(payload.folderId);
    } catch (err) {
      throw new Error("Target folder not found or inaccessible.");
    }
  } else {
    // Fallback: use the template's parent folder if possible, or root
    const parents = templateFile.getParents();
    targetFolder = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
  }

  const copy = templateFile.makeCopy(payload.filename, targetFolder);
  const newDocId = copy.getId();
  const newDocUrl = copy.getUrl();

  const tags = payload.tags || {};
  if (Object.keys(tags).length > 0) {
    replaceTagsInDocument_(newDocId, tags);
  }

  return {
    id: newDocId,
    url: newDocUrl,
    name: copy.getName(),
  };
}

/**
 * Replaces placeholders in a Google Docs document body.
 * Placeholders are expected in the form {{key}}.
 *
 * @param {string} docId - ID of the document to update.
 * @param {Object.<string, string|number|boolean|null>} tags - Map of placeholders to values.
 * @returns {void}
 */
function replaceTagsInDocument_(docId, tags) {
  const doc = DocumentApp.openById(docId);
  const body = doc.getBody();

  const entries = Object.entries(tags);

  for (const [key, rawValue] of entries) {
    const value = rawValue === null || rawValue === undefined ? "" : String(rawValue);
    const placeholder = "{{" + key + "}}";

    body.replaceText(escapeForRegex_(placeholder), value);

  }

  doc.saveAndClose();
}

/**
 * Escapes a string to be used safely inside a regular expression.
 *
 * @param {string} text - Raw text to escape.
 * @returns {string} Escaped text safe to use in a regex.
 */
function escapeForRegex_(text) {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

Web App vs Execution API vs Library, in plain terms

There are three “flavours” of Apps Script that often get mixed up:

  • Web App: you deploy doGet / doPost, you get a /exec URL.
    Perfect for what we’re doing: lightweight HTTP API.
  • Execution API: URL on script.googleapis.com like .../scripts/ID:run.
    Needs OAuth tokens, used to run script functions from other backends. Great, but heavier.
  • Library: reusable code for other Apps Script projects. Not an HTTP endpoint.

In this tutorial, the only one we need is the Web App deployment.
In the upcoming article, we will focus on how to setup OAuth tokens with the execution API.

Deploying as Web App

In the Apps Script editor:

  1. Deploy → Manage deployments → New deployment
  2. Type: Web app
  3. Execute as: Me
  4. Who has access: Anyone (or “Anyone with the link” while testing)
  5. Deploy and copy the Web app URL (ends in /exec)

Open that URL once in your browser, go through the “unverified app” screen, and accept the scopes.

From now on:

  • The script runs as you (so it can access your Docs and Drive)
  • Your backend and Postman can call the /exec URL without any extra OAuth dance

Testing the API with Postman

In Postman:

  • Method: POST
  • URL: your /exec Web App URL
  • Headers: Content-Type: application/json
  • Body → raw → JSON:
{
  "templateId": "1H1bFyR3VPI8U5CO_EDqCOOx5k_p6CFGw0HcfwyQ0vvw",
  "folderId": "1R0Izx-_pA0HYurhb25pZ2Hqd8RWOlnUG",
  "filename": "Invoice-POSTMAN-TEST-001",
  "tags": {
    "myName": "Lidia",
    "MyAddress": "Surf Camp",
    "ClientName": "Client",
    "ClientAddress": "ClientAddress",
    "Month": "November",
    "amountExcl": "120.00",
    "qty": "20",
    "Price": "100",
    "TotalTaxFree": "2000",
    "TaxRate": "20",
    "TaxTotal": "200",
    "TotalPrice": "2400"
  }
}

Click Send.
If everything is set up correctly, you should get JSON like:

And the tags have been replaced!

The weird Drive “server error” and apps-macros@system.gserviceaccount.com

When I first tried calling DriveApp.getFileById(templateId), I had the following error:

And in the console of Apps Script:

The template existed. It opened fine in Drive.
The script was running as the same user.
But inside the Apps Script project (linked to a Cloud project), Drive access was just… failing.

This error happens because Apps Script relies on a Google-managed system account behind the scenes, and that account doesn’t have the permissions it needs to run your script.
To fix it, you have to manually grant the right role to apps-macros@system.gserviceaccount.com.

You won’t see this address in the “Service Accounts” list, but it’s still a real identity in your project that you can add in IAM and assign roles to.

To fix it, I had to:

  1. Go to IAM & Admin → IAM in the Cloud Console.
  2. Click Grant access.
  3. Add a new principal:
    apps-macros@system.gserviceaccount.com
  4. Give it the role: Service Usage Admin.
  5. Save.

After that, API began returning normal JSON responses instead of that generic “server error”.

Wrapping up

Using Apps Script as an API is a nice combo when:

  • You already live in Google Workspace
  • You want invoices/letters/contracts as Docs, not PDFs generated from scratch
  • You’re happy to let Google Drive handle storage and sharing

The flow looks like this:

  1. Frontend or backend creates invoice data.
  2. Backend calls the Apps Script Web App with JSON.
  3. Apps Script copies the template, replaces tags, and responds with the document URL.
  4. You store the URL in your database and show it in your UI.

And if you ever see that mysterious Drive “server error” in a linked project, check the logs and don’t be afraid to give apps-macros@system.gserviceaccount.com the role it’s asking for.

In the next article, we’ll look at how to connect an app to this API and how to integrate Apps Script into more complex, real-world workflows.

Deploy your first backend to Cloud Run (with a Node / React-friendly example)

If you’ve been living in the comfy world of Apps Script and suddenly need “a real backend”, Google Cloud Run is a perfect next step.

You still don’t manage servers.
But now you can run any containerized app: Node, React SSR, Python, Go, whatever.

In this tutorial, we’ll:

  • Explain what Cloud Run is in simple terms.
  • Build a tiny Node.js HTTP API (which could just as well serve a React app).
  • Deploy it to Cloud Run from source with a single gcloud run deploy command (no Docker installed locally).
  • Get a public HTTPS URL you can call from your frontend (React, Apps Script, or anything).

I will also include every relevant links in order to have evrything in one place 🙂

What is Cloud Run (in human words)?

Cloud Run is a serverless platform for containers:

  • You package your code as a container image (or just push your source code and let Google build that image).
  • Cloud Run runs that container on Google’s infrastructure, scaling up and down automatically depending on traffic.
  • It can even scale to zero when nobody calls your service, so you don’t pay when it’s idle. Cloud Run documentation
  • It works with any language or framework that can run in a container and listen for HTTP requests on the port specified by the PORT environment variable. Build sources to containers

If you’re used to Apps Script web apps:

Think of Cloud Run as “Web Apps, but for any container you want, not just Apps Script.”

What we’ll build

We’ll deploy a very small Node.js backend:

  • GET / → returns a JSON message.
  • It listens on the PORT environment variable (so Cloud Run can wire it correctly).
  • We’ll deploy it using “Deploy from source”, which builds the container for you via buildpacks. Deploy services from source code

The exact same steps apply if your “backend” is a React SSR app, Next.js app, or any Node-based server.

Prerequisites

You’ll need:

  1. A Google account.
  2. A Google Cloud project with billing enabled. I personally always setup a max budget to avoid any surprise 🙂
  3. Cloud Run and Cloud Build APIs enabled (the console usually prompts you, or you can enable them manually). Quickstarts
  4. Node.js (v18+ recommended) and npm installed.
  5. Google Cloud SDK (gcloud) installed and authenticated on your machine. Deploy services from source code

Step 1 – Create the backend project

Create a new folder and init package.json:

mkdir cloud-run-backend
cd cloud-run-backend
npm init -y
npm install express

Create index.js:

const express = require("express");

/**
 * Creates an Express application with a simple JSON endpoint
 *
 * @returns {import("express").Express} The configured Express app.
 */
function createApp() {
  const app = express();

  app.get("/", (req, res) => {
    res.json({
      message: "Hello from Cloud Run",
      project: "First backend",
      timestamp: new Date().toISOString(),
    });
  });

  return app;
}

/**
 * Starts the HTTP server for the given Express app.
 *
 * @param {import("express").Express} app - The Express application instance.
 * @returns {void}
 */
function startServer(app) {
  const port = process.env.PORT || 8080; // Cloud Run injects PORT
  app.listen(port, () => {
    // eslint-disable-next-line no-console
    console.log(`Server listening on port ${port}`);
  });
}

const app = createApp();
startServer(app);

The important detail:
Cloud Run will set an environment variable PORT. Your app must listen on that port.

Step 2 – Add the start script

Open package.json and add a "start" script:

{
   "name": "cloud-run-backend",
   "version": "1.0.0",
   "main": "index.js",
   "type": "commonjs",
   "scripts": {
      "start": "node index.js"
    },
   "dependencies": {
      "express": "^4.21.0"
   }
}

Cloud Run’s Node buildpack knows how to run npm start by default.

Step 3 – Test locally

npm install
npm start

You should see:

Server listening on port 8080

In another terminal:

curl http://localhost:8080

You’ll get a JSON response with your message and a timestamp.

If it works locally, you’re ready for the cloud.

Step 4 – Configure gcloud

Log in:

gcloud auth login

Set your project and region (example: australia-southeast1):

gcloud config set project YOUR_PROJECT_ID<br>gcloud config set run/region australia-southeast1

Pick the region closest to your users.

You can find your project ID in the Cloud Overview dashboard of your project:

Step 5 – Deploy to Cloud Run from source

From inside cloud-run-backend:

gcloud run deploy cloud-run-backend \
--source . \
--allow-unauthenticated

This single command:

  1. Sends your source code to Cloud Build
  2. Builds a container image (using buildpacks or Docker under the hood)
  3. Creates a Cloud Run service called cloud-run-backend
  4. Gives it a public HTTPS URL

At the end you’ll see something like:

Service [cloud-run-backend] revision [cloud-run-backend-00001-abc]
has been deployed and is serving 100 percent of traffic at:
https://cloud-run-backend-xxxxx-uc.a.run.app

Copy that URL, that’s your backend, live on the internet 🎉

Step 6 – Call your new backend

From anywhere:

curl https://cloud-run-backend-xxxxx-uc.a.run.app

or open it in the browser.

Cloud Run gives you:

  • HTTPS by default
  • Auto-scaling
  • A generous free tier (millions of requests/month) to experiment safely

TL;DR

  • If you’ve been living in Apps Script land and need a “real backend”, Cloud Run lets you run any containerized app (Node, Python, Go, React SSR…) without managing servers.
  • You build a tiny Node.js HTTP API that listens on the PORT environment variable.
  • You deploy it with a single command: gcloud run deploy cloud-run-backend \ --source . \ --allow-unauthenticated
  • Google Cloud builds the container for you, creates a Cloud Run service, and gives you a public HTTPS URL.
  • That URL can be called from React, Apps Script, or any frontend, and Cloud Run handles scaling, HTTPS, and a generous free tier in the background.

Conclusion

Cloud Run is a really nice bridge between the simplicity of Apps Script and the flexibility of “real” backends. You keep the good parts (no servers to manage, automatic scaling, pay-per-use) and you unlock the ability to run any language or framework you like.

In this tutorial, you:

  • Created a small Node.js API
  • Tested it locally
  • Deployed it to Cloud Run from source
  • Got a live HTTPS URL you can plug into any frontend

From here, you can start wiring Cloud Run into your own projects: connect it to a React app, protect your endpoints with authentication, or add a database like Firestore or Cloud SQL.

If you try this out or deploy your own mini SaaS on Cloud Run, let me know, I’d love to see what you build !