How to Save AI-Generated Images to S3 and GCS via API
You call the image generation API, get back a URL, and everything looks fine — until your app tries to display that image 48 hours later and gets a 403. AI image APIs return temporary CDN URLs. In production, you need a pipeline that generates the image and immediately persists it to your own storage.
This guide covers the complete flow: generating an image with GenRelay (using Nano Banana Pro or GPT-image-2), downloading the result, and uploading it to Amazon S3 or Google Cloud Storage. All examples are copy-paste ready.
Why do AI image API URLs expire?
Temporary URLs are standard across generative media APIs. The image is rendered to ephemeral storage on the provider's CDN and cleaned up after a short window — typically 24 to 72 hours. This keeps provider infrastructure costs bounded and keeps ownership of image delivery with you.
For production use, treat the returned URL as a one-time download link: fetch the bytes immediately after generation and push them to your own bucket. If your architecture processes images asynchronously (e.g. via a job queue), make sure the download step happens before the URL expiry, not when the downstream task eventually runs.
How do I generate an image and upload it to Amazon S3?
The pattern is three steps: generate → download → upload. Here is a complete Python example using Nano Banana Pro via GenRelay and boto3 for S3:
import requests
import boto3
from uuid import uuid4
GENRELAY_KEY = "YOUR_GENRELAY_KEY"
S3_BUCKET = "your-bucket-name"
S3_REGION = "us-east-1"
def generate_and_upload_s3(prompt: str, resolution: str = "1024x1024") -> str:
# 1. Generate image
gen_resp = requests.post(
"https://api.genrelay.ai/v1/images/generations",
headers={"Authorization": f"Bearer {GENRELAY_KEY}"},
json={
"model": "nano-banana-pro",
"prompt": prompt,
"size": resolution,
"n": 1,
},
timeout=60,
)
gen_resp.raise_for_status()
image_url = gen_resp.json()["data"][0]["url"]
# 2. Download image bytes
img_resp = requests.get(image_url, timeout=30)
img_resp.raise_for_status()
img_bytes = img_resp.content
content_type = img_resp.headers.get("Content-Type", "image/png")
# 3. Upload to S3
s3 = boto3.client("s3", region_name=S3_REGION)
key = f"ai-images/{uuid4()}.png"
s3.put_object(
Bucket=S3_BUCKET,
Key=key,
Body=img_bytes,
ContentType=content_type,
)
return f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com/{key}"
url = generate_and_upload_s3("a developer working at a standing desk, photorealistic")
print(url) # https://your-bucket.s3.us-east-1.amazonaws.com/ai-images/<uuid>.png
A few production details worth noting:
- Use
Content-Typefrom the response header rather than hardcodingimage/png— GPT-image-2 returns PNG by default, but this may vary. - Use a UUID or a hash of
prompt + timestampas the S3 key to avoid collisions in high-volume pipelines. - Grant your EC2 or Lambda execution role
s3:PutObjecton the target bucket rather than embedding AWS credentials in code.
How do I save generated images to Google Cloud Storage?
The structure is identical; only the upload library changes. Here is the same flow using google-cloud-storage:
import requests
from google.cloud import storage
from uuid import uuid4
GENRELAY_KEY = "YOUR_GENRELAY_KEY"
GCS_BUCKET = "your-gcs-bucket"
def generate_and_upload_gcs(prompt: str) -> str:
gen_resp = requests.post(
"https://api.genrelay.ai/v1/images/generations",
headers={"Authorization": f"Bearer {GENRELAY_KEY}"},
json={"model": "gpt-image-2", "prompt": prompt, "n": 1},
timeout=60,
)
gen_resp.raise_for_status()
image_url = gen_resp.json()["data"][0]["url"]
img_bytes = requests.get(image_url, timeout=30).content
client = storage.Client()
bucket = client.bucket(GCS_BUCKET)
blob = bucket.blob(f"ai-images/{uuid4()}.png")
blob.upload_from_string(img_bytes, content_type="image/png")
blob.make_public()
return blob.public_url
url = generate_and_upload_gcs("product shot of wireless earbuds on white background")
print(url)
If images should be private and served through short-lived URLs, replace blob.make_public() with a signed URL:
from datetime import timedelta
signed_url = blob.generate_signed_url(
expiration=timedelta(hours=1),
method="GET",
)
Signed URLs are appropriate when images contain user-specific content or when you want download tracking through your backend.
How should I choose between Nano Banana Pro and GPT-image-2 for this workflow?
The choice affects both generation cost and typical file size, which flows into storage and CDN egress.
| Model | Typical 1K PNG size | Generation cost | Best fit |
|---|---|---|---|
| Nano Banana Pro (1K) | ~800 KB | $0.030/image | Photorealistic scenes, reference-guided generation |
| Nano Banana 2 (1K) | ~600 KB | $0.020/image | High-volume, cost-sensitive pipelines |
| GPT-image-2 | ~500 KB | $0.014/image | Instruction-based edits, text-in-image tasks |
At 4K resolution, Nano Banana Pro costs $0.042/image and Nano Banana 2 costs $0.036/image. For pipelines generating thousands of images daily, switching from Nano Banana Pro to Nano Banana 2 at 1K resolution cuts model cost by 33% while still delivering strong output quality. See the image generation API guide for a full parameter breakdown, or the Nano Banana Pro vs Nano Banana 2 comparison if you are weighing the two.
S3 Standard storage at approximately $0.023/GB/month means 10,000 images at ~800 KB each costs roughly $0.18/month in storage — essentially free compared to generation costs. The relevant cost levers are model selection and CDN egress, not storage.
How do I handle image format conversion before uploading?
Some workflows require JPEG for smaller file sizes or compatibility with downstream systems. Convert in-memory with Pillow before uploading:
from PIL import Image
import io
def png_to_jpeg(png_bytes: bytes, quality: int = 85) -> bytes:
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
A JPEG at quality 85 is typically 3–5× smaller than the equivalent PNG. At 10,000 images/day that difference compounds quickly in egress costs if images are served from S3 directly.
How do I scale this pattern for batch generation?
For pipelines generating 100+ images concurrently, combine the S3 upload pattern with asyncio. Here is an async version using aiohttp and aioboto3:
import asyncio
import aiohttp
import aioboto3
from uuid import uuid4
async def batch_generate_and_upload(prompts: list[str]) -> list[str]:
boto_session = aioboto3.Session()
async with aiohttp.ClientSession() as http:
async with boto_session.client("s3", region_name="us-east-1") as s3:
tasks = [_one(http, s3, p) for p in prompts]
return await asyncio.gather(*tasks)
async def _one(http, s3, prompt: str) -> str:
async with http.post(
"https://api.genrelay.ai/v1/images/generations",
headers={"Authorization": "Bearer YOUR_KEY"},
json={"model": "nano-banana-2", "prompt": prompt, "n": 1},
) as r:
data = await r.json()
image_url = data["data"][0]["url"]
async with http.get(image_url) as r:
img_bytes = await r.read()
key = f"ai-images/{uuid4()}.png"
await s3.put_object(Bucket="your-bucket", Key=key, Body=img_bytes, ContentType="image/png")
return key
keys = asyncio.run(batch_generate_and_upload(["red sneakers", "blue sneakers", "white sneakers"]))
For concurrency limits, retry logic, and cost math at different batch sizes, see the batch image generation API guide.
FAQ
Do AI-generated image URLs always expire?
Yes. GenRelay returns temporary CDN URLs for generated images. Expiry windows vary by model but are typically 24–72 hours. For production use, download and re-host immediately after generation — do not assume the URL will be accessible later.
Can I stream image bytes directly to S3 without loading them into memory?
For images under a few MB, in-memory handling is straightforward. For 4K PNGs (which can exceed 5 MB), use S3's multipart upload API or pipe the response stream directly into the upload to avoid memory pressure on serverless functions with tight limits.
What IAM permissions does my Lambda or EC2 role need?
At minimum: s3:PutObject on the target bucket. If you are setting object ACLs to public: add s3:PutObjectAcl. Prefer IAM execution roles over access key credentials for production deployments.
Does GenRelay support presigned upload URLs for direct browser uploads?
Image generation happens server-side — there is no direct browser upload path for generated images. Your backend generates the image, downloads the result, and handles the upload to cloud storage, then returns a permanent URL to the client.
Which model gives the lowest combined generation + storage cost for high-volume pipelines?
GPT-image-2 at $0.014/image has the lowest generation cost. For workflows where instruction-based generation or text-in-image tasks fit the use case, it is the most cost-efficient option. For photorealistic generation at scale, Nano Banana 2 at $0.020/image (1K) is a strong middle ground.
As of September 2026, pricing reflects GenRelay's published rates. Cloud storage and egress costs vary by provider and region — consult your cloud provider's current pricing calculator for accurate estimates.