Storage hook on Google Cloud Run

Read this if your generated media lands in a Google Cloud Storage bucket and you want it marked in place by a service you do not have to keep running yourself. You will deploy the storage hook as a private Cloud Run service that Eventarc (or Pub/Sub) pushes object-finalize events to. Skip if your media is in S3 (AWS S3 with SQS, AWS Lambda) or R2 (the R2 section). Time: about an hour. Needs: a Google Cloud project, a GCS bucket, a container image of Laidline in Artifact Registry, a signing key and certificate chain, and a durable evidence sink (postgres or s3).

In short

  • Object-finalize events reach a private Cloud Run service as HTTP pushes; the HTTP status is the acknowledgement.
  • The service marks each object in place with the same IngestWorker as laidline ingest, using Application Default Credentials.
  • The first evidence sink must be durable (postgres or s3); with a file sink the service answers 503.
  • The first push on a fresh instance takes 33 to 48 s, because TrustMark loads on the first mark.
  • Prefer Pub/Sub with a dead-letter policy over Eventarc when retries must stop: Eventarc carries no attempt counter.

The storage hook for Google Cloud Storage: object-finalize events reach a Cloud Run service as HTTP pushes, either from an Eventarc trigger (a CloudEvent in binary mode) or from a Pub/Sub push subscription on a GCS notification (a Pub/Sub envelope). laidline.ingest.cloudrun:create_app marks each object in place with the same IngestWorker as laidline ingest.

What you need

  • APIs. Enable (gcloud services enable) run.googleapis.com, artifactregistry.googleapis.com, eventarc.googleapis.com, pubsub.googleapis.com, secretmanager.googleapis.com and iam.googleapis.com. Enabling these can grant the default compute service account roles/editor; check for it and remove it if your policy forbids it.

  • Image. The Dockerfile below: a Python 3.12 image with ffmpeg, CPU torch (which keeps it small), the extras image,audio,video,server,evidence,kms,ingest,gcs and the TrustMark weights baked in, running as a non-root user. Build it on a native Linux filesystem such as ext4: from a network or Windows-mounted drive the build can fail with "cannot allocate memory" on the ONNX graph. Push it to Artifact Registry.

    FROM python:3.12-slim
    RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* \
     && useradd --uid 10001 --create-home laidline \
     && mkdir -p /var/lib/laidline && chown laidline /var/lib/laidline
    RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
     && pip install --no-cache-dir "laidline[image,audio,video,server,evidence,kms,ingest,gcs]==1.0.1"
    RUN python -c "from trustmark import TrustMark; [TrustMark(verbose=False, model_type=v, encoding_type=TrustMark.Encoding.BCH_5, loadRemover=False) for v in 'QBC']" \
     && chmod -R a+rX "$(python -c 'import trustmark, os; print(os.path.dirname(trustmark.__file__))')"
    ENV LAIDLINE_HOME=/var/lib/laidline
    USER laidline
    EXPOSE 8080
    CMD exec uvicorn --factory laidline.ingest.cloudrun:create_app --host 0.0.0.0 --port ${PORT:-8080}
    

    An image installed from pip has no ONNX graph for the TrustMark Q keyframe reader, so that reader runs on torch (slower, same result): see Third-party licences. The image pins laidline==1.0.1; change the version to the release you want.

    The image carries no configuration: the service mounts it from Secret Manager and sets LAIDLINE_CONFIG (see "Service" below and step 2). LAIDLINE_HOME is the writable directory where the AudioSeal and VideoSeal weights download on first use.

  • Service. Private (--no-allow-unauthenticated). Mount the config, the signing key and the chain from Secret Manager into separate directories and set LAIDLINE_CONFIG=/etc/laidline/conf/config.yaml. The proven service used 1 vCPU, 2 GiB, --cpu-boost, a 300 s timeout, concurrency 1 and one instance at most (see "Cold start" below).

  • Service identity. roles/storage.objectAdmin on the bucket and roles/secretmanager.secretAccessor on the secrets it reads; nothing more was needed.

  • Eventarc. A trigger on google.cloud.storage.object.v1.finalized for the bucket with --destination-run-path /. Its service account needs roles/eventarc.eventReceiver on the project and roles/run.invoker on the service, and the Cloud Storage service agent (gcloud storage service-agent) needs roles/pubsub.publisher on the project. In a fresh project the Eventarc service agent can take a few minutes to propagate: if the trigger create fails, wait and retry.

Setup

1. Write the configuration

Store this as a secret and mount it. Evidence goes to Postgres (Cloud SQL) or S3; the example uses S3.

system: {name: acme-flux, version: "2026.10"}
signing:
  backend: local
  local: {key_path: /etc/laidline/key/key.pem}
  cert_chain_path: /etc/laidline/chain/chain.pem
watermark:
  image: {scheme: trustmark}
# paste the watermark and payload_keys blocks that laidline init wrote; never hand-write watermark.issuer_id
evidence:
  sinks:
    - {type: s3, bucket: <evidence bucket>, object_lock: true, region: <region>}
ingest:
  prefixes: ["generated/"]
  gcs: {project: <project>}

A key file from Secret Manager suits a trial, which is what the proven run used. For production sign with a key that never leaves its store, such as vault-transit (Certificates and keys).

Evidence on Cloud SQL for PostgreSQL goes through the Cloud SQL connector or the Cloud SQL Auth Proxy; the sink reads its DSN from the variable named by dsn_env (default LAIDLINE_PG_DSN). Create the schema with the migrator role (laidline evidence init-db) rather than auto_init in production (Evidence).

What just happened: you pinned the TrustMark scheme (the same reason as in AWS Lambda), chose a durable evidence sink and told the service which project holds the bucket.

2. Deploy the service

gcloud run deploy laidline-ingest --image <registry>/laidline:<tag> \
  --no-allow-unauthenticated --cpu 1 --memory 2Gi --cpu-boost --concurrency 1 --max-instances 1 --timeout 300 \
  --set-env-vars LAIDLINE_CONFIG=/etc/laidline/conf/config.yaml

Mount the secrets (--set-secrets or --update-secrets with the paths above) and give the service the identity under "What you need". Deploy with --no-cpu-throttling as well so the checkpoint timer keeps CPU between requests.

What just happened: a private service now listens on $PORT; /readyz answers 403 without a token.

3. Create the trigger

Either Eventarc (no attempt count; see Retries below):

gcloud eventarc triggers create laidline-finalized --location <region> \
  --event-filters type=google.cloud.storage.object.v1.finalized --event-filters bucket=<bucket> \
  --destination-run-service laidline-ingest --destination-run-region <region> --destination-run-path / \
  --service-account <trigger service account>

Or a GCS notification to Pub/Sub, with a push subscription that has a dead-letter policy (--max-delivery-attempts at least ingest.max_attempts):

gcloud storage buckets notifications create gs://<bucket> --topic <topic> --event-types OBJECT_FINALIZE --payload-format json
gcloud pubsub subscriptions create <subscription> --topic <topic> --push-endpoint https://<service url>/ \
  --push-auth-service-account <account> --dead-letter-topic <dead-letter topic> --max-delivery-attempts 5

The Pub/Sub service agent must be able to move messages to the dead-letter topic:

gcloud pubsub topics add-iam-policy-binding <dead-letter topic> \
  --member serviceAccount:service-<project number>@gcp-sa-pubsub.iam.gserviceaccount.com --role roles/pubsub.publisher
gcloud pubsub subscriptions add-iam-policy-binding <subscription> \
  --member serviceAccount:service-<project number>@gcp-sa-pubsub.iam.gserviceaccount.com --role roles/pubsub.subscriber

What just happened: every object created in the bucket now produces a push to the service, which marks it in place.

Check it worked

curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" <service url>/readyz

Expected output: a 200 answer. Without the token the answer is 403. Then upload an image under generated/:

gcloud storage cp in.png gs://<bucket>/generated/in.png

Download it again and run laidline detect on it: the verdict is marked_by_this_system. The service's log shows ingest ... outcome=marked for the object.

How it works

  • Image. The service runs uvicorn --factory laidline.ingest.cloudrun:create_app on $PORT. The image carries the audio and video extras (about 150 MB more on disk): audio objects whose type is in ingest.mime_allowlist are AudioSeal-marked, and the AudioSeal weights download on first use into $LAIDLINE_HOME/models on the instance (Audio). The live run marked images only; the default allowlist skipped its WAV.
  • Configuration. LAIDLINE_CONFIG as usual. Objects are read and written with Application Default Credentials; ingest.gcs.project sets the project and ingest.gcs.endpoint_url (or STORAGE_EMULATOR_HOST) points at an emulator. ingest.prefixes, mime_allowlist, max_bytes, writeback and max_attempts apply as for the other sources (Configuration); evidence entries carry route: ingest:gcs_pubsub for both delivery paths.
  • Durable evidence. With a file sink as evidence.sinks[0] the service starts but /readyz answers 503 and every push gets a 503: Cloud Run's filesystem is gone with the instance. Use postgres (Cloud SQL) or s3.
  • Checkpoints. The background checkpoint timer runs in the service; deploy with --no-cpu-throttling so it keeps CPU between requests.
  • Cold start. Measured (1 vCPU, 2 GiB, --cpu-boost): 14.5 s from instance start to ready. The first push on a fresh instance then took 33 to 48 s, because TrustMark loads on the first mark (about 12 s of it); later marks took about 1 to 1.4 s and skips 0.03 to 0.07 s. With --concurrency 1 and --max-instances 1, pushes that arrived together got platform 429s (no available instance), which Eventarc redelivered, so nothing was lost but marking was delayed. Allow more concurrency or more instances.
  • Evidence streams. On Cloud Run each instance writes its own stream, named localhost-<hash>.

Acknowledgement

The HTTP status is the ack:

Reply When
204 marked; skipped (already marked, outside the prefixes, too large, not an allowed type); failed on the last attempt or with a non-retryable error (ingest failed ... at ERROR); the object no longer exists (cloudrun: skipped_missing bucket=<b> key=<k> at WARNING); not an event this service handles (logged at INFO)
503 a retryable error before the last attempt (storage, evidence sink, TSA, signing), or the service is not ready (a WARNING naming the bucket and key)

A confirmed-missing object is terminal: when the worker's storage call fails and a head then reports the object missing (GCS is strongly consistent), the push is acknowledged with 204 skipped_missing instead of retried. Unrecognised bodies get a 204, never a 4xx, because a 4xx makes Pub/Sub redeliver a poison message until it expires.

Retries: prefer Pub/Sub with a dead-letter policy

  • Eventarc carries no attempt counter, so every delivery is attempt 1. A persistent error answers 503 and, per Google's documentation, Eventarc keeps redelivering with backoff for up to 24 hours before discarding the event; the object stays unmarked and shows only in the WARNING line of each attempt.

  • Pub/Sub push carries deliveryAttempt only when the subscription has a dead-letter policy. With one, the worker answers 503 until attempt ingest.max_attempts (default 5), then logs ingest failed at ERROR and answers 204. Configure:

    • a dead-letter topic with max_delivery_attempts greater than or equal to ingest.max_attempts; with fewer, Pub/Sub dead-letters the message before the worker's last attempt and the failure never reaches the Laidline log;
    • the Pub/Sub service agent (service-<project-number>@gcp-sa-pubsub.iam.gserviceaccount.com) with roles/pubsub.publisher on the dead-letter topic and roles/pubsub.subscriber on the subscription, or dead-lettering does not happen.

    Google documents deliveryAttempt as approximate. This retry behaviour is Google's documented behaviour.

Limits

  • Eventarc retries are unbounded for 24 hours and uncounted; use the Pub/Sub path when you need a dead-letter queue.
  • The first push on a fresh instance takes 33 to 48 s (Cold start); keep one instance warm or allow more concurrency if that matters.
  • The service does no authentication itself. --no-allow-unauthenticated makes Google's front end check the push identity, so keep it on.
  • One evidence stream per instance (localhost-<hash>).

What was proven

Proven live on 21 Oct 2026 on Cloud Run in europe-west3 with an Eventarc google.cloud.storage.object.v1.finalized trigger and evidence in an S3 bucket: 4 of 4 uploaded images (JPEG and PNG, in two runs) were marked in place and detected locally (C2PA valid and trusted by the run's development CA, watermark found), the S3 evidence chains verified, and the closing checkpoints were written when the service scaled down and when it was deleted. A WAV was skipped by the default allowlist, and the skip is logged. Push authentication (--no-allow-unauthenticated) held: a request without a token got 403.

The service also ran against fake-gcs-server 1.56.1 with evidence in Postgres 16, in process and as a container, with CloudEvent and Pub/Sub push payloads built from Google's documentation.

Not proven: a GCS notification with a Pub/Sub push subscription and dead-letter policy, and Cloud SQL. The Pub/Sub push bodies come from Google's documentation, and the retry and dead-letter behaviour above is Google's documented behaviour, not observed; Eventarc retries were seen only as the platform's own 429 redeliveries. The live run signed with a development key and CA (trusted only by the local checker), not KMS, and ran without --no-cpu-throttling, so only the closing checkpoints were written, not the timer's.

Troubleshooting

Message Cause Fix
/readyz answers 403 the request carries no identity token call it with Authorization: Bearer $(gcloud auth print-identity-token)
/readyz answers 503 and every push gets a 503 evidence.sinks[0] is a file sink use postgres (Cloud SQL) or s3
cannot allocate memory while building the build context sits on a network or Windows-mounted drive build from a native Linux filesystem such as ext4
the trigger create fails in a fresh project the Eventarc service agent has not propagated yet wait a few minutes and retry
429 from the platform, deliveries delayed --concurrency 1 with --max-instances 1 and pushes arriving together allow more concurrency or more instances; Eventarc redelivers
cloudrun: skipped_missing bucket=<b> key=<k> the object was deleted before the push was handled none; it is acknowledged with 204
the object stays unmarked and the log shows only WARNING lines a persistent error under Eventarc, which has no attempt counter fix the cause; the event redelivers for up to 24 hours, or use the Pub/Sub path
ingest failed never appears in the Laidline log the dead-letter topic's max_delivery_attempts is below ingest.max_attempts set it greater than or equal to ingest.max_attempts

Reference

  • AWS S3 with SQS: the worker, its outcomes and the retry rules this service shares.
  • Configuration: the ingest keys, including gcs.project and gcs.endpoint_url.
  • Evidence: the postgres and s3 sinks.
  • AWS Lambda: why the container pins TrustMark.
  • Audio: marking audio objects.