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 (
postgresors3).
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
IngestWorkeraslaidline ingest, using Application Default Credentials. - The first evidence sink must be durable (
postgresors3); with afilesink 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.comandiam.googleapis.com. Enabling these can grant the default compute service accountroles/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 extrasimage,audio,video,server,evidence,kms,ingest,gcsand 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_HOMEis 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 setLAIDLINE_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.objectAdminon the bucket androles/secretmanager.secretAccessoron the secrets it reads; nothing more was needed. -
Eventarc. A trigger on
google.cloud.storage.object.v1.finalizedfor the bucket with--destination-run-path /. Its service account needsroles/eventarc.eventReceiveron the project androles/run.invokeron the service, and the Cloud Storage service agent (gcloud storage service-agent) needsroles/pubsub.publisheron 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_appon$PORT. The image carries the audio and video extras (about 150 MB more on disk): audio objects whose type is iningest.mime_allowlistare AudioSeal-marked, and the AudioSeal weights download on first use into$LAIDLINE_HOME/modelson the instance (Audio). The live run marked images only; the default allowlist skipped its WAV. - Configuration.
LAIDLINE_CONFIGas usual. Objects are read and written with Application Default Credentials;ingest.gcs.projectsets the project andingest.gcs.endpoint_url(orSTORAGE_EMULATOR_HOST) points at an emulator.ingest.prefixes,mime_allowlist,max_bytes,writebackandmax_attemptsapply as for the other sources (Configuration); evidence entries carryroute: ingest:gcs_pubsubfor both delivery paths. - Durable evidence. With a
filesink asevidence.sinks[0]the service starts but/readyzanswers 503 and every push gets a 503: Cloud Run's filesystem is gone with the instance. Usepostgres(Cloud SQL) ors3. - Checkpoints. The background checkpoint timer runs in the service; deploy with
--no-cpu-throttlingso 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 1and--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
deliveryAttemptonly when the subscription has a dead-letter policy. With one, the worker answers 503 until attemptingest.max_attempts(default 5), then logsingest failedat ERROR and answers 204. Configure:- a dead-letter topic with
max_delivery_attemptsgreater than or equal toingest.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) withroles/pubsub.publisheron the dead-letter topic androles/pubsub.subscriberon the subscription, or dead-lettering does not happen.
Google documents
deliveryAttemptas approximate. This retry behaviour is Google's documented behaviour. - a dead-letter topic with
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-unauthenticatedmakes 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
ingestkeys, includinggcs.projectandgcs.endpoint_url. - Evidence: the
postgresands3sinks. - AWS Lambda: why the container pins TrustMark.
- Audio: marking audio objects.