Ukrainian Flag
Russia is still destroying lives. Click here to help.
Ukrainian Flag
me
Jakub KoleΕΌyΕ„ski
Blog Talks Apps
Free backups using Google Drive

Free backups using Google Drive

10.09.2026

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.

The trodden path

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."

But I shared the folder!

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:

  • folder found βœ…
  • canAddChildren: true βœ…
  • storage quota limit: 0

So 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.

"Solutions"

The error message helpfully suggests two ways out:

  • Shared drives - a Google Workspace feature.
  • OAuth delegation - also a Google Workspace feature.

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.

The question that fixed it

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.

Backups that never create a file

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:

  1. Lists the shared folder's contents
  2. Looks for a dummy - a file whose name doesn't match the naming convention
  3. If there are no dummies left, looks for the oldest backup by timestamp in the names
  4. Recycles that file into a new backup with a single PATCH

Thirty files, thirty days of rolling history, job done.

The dotenv trap, free of charge

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.

Worth it?

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.

  • Free βœ…
  • Off the VPS βœ…
  • Boring 🀷 - depends who you ask. But it's not the adrenaline kind of excitement, so βœ…



Back to list

Comments:

Sign In to comment

Be the first to comment!