Skip to content

10 — Cloud Storage

Previous: 09 — Secret Manager

Cloud Storage (GCS) stores static files and user-uploaded media. We'll create two buckets with Terraform: one for static files (CSS, JS) and one for media (user uploads).


Why two buckets?

Bucket Contents Who writes Who reads
static CSS, JS, icons — from collectstatic Django (at deploy time) Browsers directly
media User-uploaded images, avatars Django (at runtime) Browsers directly

Both are publicly readable so browsers can fetch files directly without going through Django.


Create the buckets with Terraform

Add to infrastructure/main.tf:

# Cloud Storage buckets
resource "google_storage_bucket" "static" {
  name          = "${var.project_id}-static"
  location      = var.region
  force_destroy = true

  # Uniform bucket-level access (simpler, recommended)
  uniform_bucket_level_access = true

  # Public access prevention (keep off so we can make public)
  public_access_prevention = "inherited"

  # CORS for font loading from web fonts (if needed)
  # cors {
  #   origin          = ["https://mycoolproject.com"]
  #   method          = ["GET"]
  #   response_header = ["Content-Type"]
  #   max_age_seconds = 3600
  # }
}

resource "google_storage_bucket" "media" {
  name          = "${var.project_id}-media"
  location      = var.region
  force_destroy = true

  uniform_bucket_level_access = true
  public_access_prevention    = "inherited"
}

# Make static bucket publicly readable
resource "google_storage_bucket_iam_member" "static_public" {
  bucket = google_storage_bucket.static.name
  role   = "roles/storage.objectViewer"
  member = "allUsers"
}

# Make media bucket publicly readable
resource "google_storage_bucket_iam_member" "media_public" {
  bucket = google_storage_bucket.media.name
  role   = "roles/storage.objectViewer"
  member = "allUsers"
}

# Allow Cloud Run service account to write to both buckets
resource "google_storage_bucket_iam_member" "run_static_admin" {
  bucket = google_storage_bucket.static.name
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:${google_service_account.run.email}"
}

resource "google_storage_bucket_iam_member" "run_media_admin" {
  bucket = google_storage_bucket.media.name
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:${google_service_account.run.email}"
}

output "static_bucket" {
  value = google_storage_bucket.static.name
}

output "media_bucket" {
  value = google_storage_bucket.media.name
}

Run terraform apply to create both buckets.


What gets created

Bucket name URL Purpose
mycoolproject-prod-static https://storage.googleapis.com/mycoolproject-prod-static/ CSS, JS, fonts
mycoolproject-prod-media https://storage.googleapis.com/mycoolproject-prod-media/ User uploads

Django configuration

In web/core/settings/prod.py:

STORAGES = {
    # Default storage: user uploads go here
    "default": {
        "BACKEND": "storages.backends.gcloud.GoogleCloudStorage",
        "OPTIONS": {
            "bucket_name": "${var.project_id}-media",
        },
    },
    # Static files: collectstatic uploads here
    "staticfiles": {
        "BACKEND": "storages.backends.gcloud.GoogleCloudStorage",
        "OPTIONS": {
            "bucket_name": "${var.project_id}-static",
            "default_acl": None,  # Removes public ACL, relies on bucket policy
            "object_parameters": {
                "cache_control": "public, max-age=31536000",
            },
        },
    },
}

GS_PROJECT_ID = var.project_id
STATIC_URL = f"https://storage.googleapis.com/${var.project_id}-static/"
MEDIA_URL = f"https://storage.googleapis.com/${var.project_id}-media/"

Collectstatic: upload static files

Before deploying, run collectstatic to upload CSS, JS, and fonts to GCS:

cd web
DJANGO_SETTINGS_MODULE=core.settings.prod uv run manage.py collectstatic --noinput

This uploads all static files to the static bucket. Django's GCS backend handles this automatically via the storages library.


No media files to collect

Media files (user uploads) are uploaded at runtime when users submit forms. There's no collectstatic equivalent — Django writes directly to GCS via the storages backend.


Verify buckets exist

gsutil ls

You should see:

gs://mycoolproject-prod-static/
gs://mycoolproject-prod-media/