
I wanted nightly backups for a small self-hosted app. A couple hundred kilobytes of SQLite, nothing fancy. Free, boring, and off the VPS - that's the whole requirement.
I've got the free 15GB just sitting there in Google Drive doing nothing. So that's where they should go, right?
Google had opinions.
Every guide says the same thing. Make a service account, download its JSON key, share your Drive folder with the service account's email address, upload. Fine.
Didn't even bother with the SDK, went the suckless AI route for the one endpoint I needed.
Token in hand, multipart upload, parent folder goes here, byte buffer in the body, bada bing bada boom.
π΄ 403 storageQuotaExceeded
"Service Accounts do not have storage quota. Leverage shared drives,
or use OAuth delegation instead."
To an un-motivated eye this looks like a permissions problem. It isn't.
I checked the shared folder access using service account's token, just to be sure:
canAddChildren: true β
0So the service account absolutely can write into my folder. What it cannot do is own anything.
Turns out, Google Drive storage is billed to whoever owns the file. Upload a file and the uploader owns it. The bytes land on the service account's tab, and that tab is zero bytes.
Sharing a folder grants access. It doesn't give away ownership... duh.
The error message helpfully suggests two ways out:
I've just got a gmail address. The #1 requirement was: do this for free.
There is a third, semi-obvious way, which is to do the OAuth dance as myself and upload as a real user. But now I have a refresh token to keep alive, and a consent screen I have to publish, and a backup job that that silently dies after a week.
Ok, so the service account can't own a file...
What if it doesn't?
Renaming is just a metadata PATCH. Replacing contents is files.update. Neither one transfers ownership. And if ownership never changes, the bytes stay "billed" to the free personal account.
Which means it should be able to take an existing file replace it completely. New name, new contents, same file.
It can, and in a single request:
async function updateFile(fileId, fileName, bytes) {
const metadata = JSON.stringify({ name: fileName });
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n` +
`--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--`),
]);
const res = await fetch(
`https://www.googleapis.com/upload/drive/v3/files/${slot.id}?uploadType=multipart`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
},
body,
},
);
}
300KB in, 300KB back out, byte for byte identical, not a peep about quota.
So the trick is that the job never creates anything. It only ever recycles a file that already exists. That also means the number of files dabbles as a retention policy. So I made 30 dummy files for each environment, for a cool 1 month retention.
Note to self: limit is 100, because listing files is paginated and it's a whole thing, couldn't be bothered.
So now, the job that runs every night:
PATCHThirty files, thirty days of rolling history, job done.
One more, because it cost me a good while. The service account key goes into .env in single quotes:
GDRIVE_JSON='{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----\n..."}'
Use double quotes and dotenv expands the \n escapes inside private_key for you. Very helpful.
Absolutely, and I'll likely do it again.
It's free forever, nothing expires, there's no new account to sign up for, no token to renew. Thirty days of rolling history for about ten minutes of plumbing.