AI Image Gallery with Flask and S3

Build a Flask image gallery that stores photos in S3 with AI captions.

Introduction

30 Second Summary

You have hundreds of photos on your phone, but finding that one sunset shot from last summer means scrolling through an endless camera roll. What if every image came with a description that told you exactly what was in it?

In this project, you will build an image gallery web app using Flask, AWS S3, and Google Gemini. You will upload photos, let AI generate captions for each one, edit them to your liking, and serve the images securely from the cloud.

What You'll Build

Picture uploading a photo, watching an AI write a two-sentence caption, tweaking the words to get them just right, and then browsing your gallery where every image loads through a secure, time-limited link straight from the cloud.

By the end of this project, you'll have:

  • A two-step upload flow where you pick an image, preview it alongside an editable AI-generated description, and confirm before saving.
  • A gallery page displaying every uploaded image with its finalized caption, each photo served securely through presigned URLs from a private S3 bucket.
  • A delete button on every image card that removes the photo from both cloud storage and the gallery in one click.
  • Secret Mission: Add keyword search to filter your gallery by what the captions describe.

Are there any prerequisites?

You need Python 3.10 or higher and PyCharm installed on your machine. You also need a Google Gemini API key ready to use.

You will set up your AWS account and install the AWS CLI during the project.

Before We Start

Before you start building, take a moment to lock in what you are about to create and why it matters.

Set Up Your Environment

Before you can build the image gallery, you need the right foundation in place. That means a Python project with the correct packages, an AWS account connected to your terminal, and an S3 bucket ready to store images.

In this step, you will create a Python virtual environment, install Flask and boto3, set up the AWS CLI with IAM credentials, and create your S3 bucket.

In this step, get ready to:
  • Install Python packages and configure your API key.
  • Set up an AWS account and the AWS CLI.
  • Create an S3 bucket for image storage.
Create your project folder and install packages

Every Python project starts with an isolated environment so its dependencies do not clash with other projects on your machine.

  • Open a terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ai-image-gallery
cd ai-image-gallery
  • Create a Python virtual environment by running this command:

macOS

python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

Windows

python -m venv venv
  • Activate the virtual environment by running:
venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms you are working inside the virtual environment.

Don't see (venv) in your prompt?

Make sure you ran the activate command for your operating system. On Windows, also confirm you are using PowerShell and not Command Prompt.

help me activate my Python virtual environment

Now you will create the two configuration files your project needs. Open PyCharm so you can edit files in a proper editor.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type PyCharm and press Enter to open it.
  • In PyCharm, click Open on the welcome screen.
  • Navigate to your Desktop and select the ai-image-gallery folder, then click OK.
  • Right-click the ai-image-gallery folder in the Project panel on the left.
  • Select New, then File.
  • Name the file requirements.txt and press Enter.
  • Paste the following content into requirements.txt:
flask==3.1.3
boto3==1.43.78
google-genai==2.19.0
python-dotenv==1.2.3

What are these packages?

  • flask is the web framework that powers your gallery app.
  • boto3 is the AWS SDK for Python. You will use it to upload files to S3 and generate presigned URLs.
  • google-genai is the Google Gemini SDK. It sends your images to Gemini for AI-generated captions.
  • python-dotenv loads environment variables from a .env file so you can keep secrets out of your code.
  • Save requirements.txt with Cmd+S (macOS) or Ctrl+S (Windows).
  • Install the packages by switching to your terminal and running:
pip install -r requirements.txt

You should see output scrolling as each package downloads and installs. The final line reads something like Successfully installed followed by the package names.

Seeing an install error?

Make sure (venv) still appears in your prompt. If it disappeared, re-run the activate command from earlier before retrying the install.

help me fix my pip install error

Next, create your environment file to store your Gemini API key and bucket configuration.

  • In PyCharm, right-click the ai-image-gallery folder in the Project panel.
  • Select New, then File.
  • Name the file .env and press Enter.
  • Paste the following content into .env:
GOOGLE_API_KEY=your-api-key-here
S3_BUCKET_NAME=your-bucket-name-here
AWS_REGION=us-east-1
  • Replace your-api-key-here with the Google Gemini API key you already have.
  • Leave S3_BUCKET_NAME as your-bucket-name-here for now. You will update it after creating your S3 bucket.
  • Save .env with Cmd+S (macOS) or Ctrl+S (Windows).

Why not put the API key in code?

Storing secrets in a .env file keeps them out of your source code. If you ever share or push your project, the key stays private as long as .env is excluded from version control.

  • Confirm both files exist by running this in your terminal:
ls

You should see .env, requirements.txt, and the venv folder listed.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Your requirements.txt should contain exactly:

flask==3.1.3
boto3==1.43.78
google-genai==2.19.0
python-dotenv==1.2.3

Your .env should contain exactly (with your real API key in place of the placeholder):

GOOGLE_API_KEY=your-api-key-here
S3_BUCKET_NAME=your-bucket-name-here
AWS_REGION=us-east-1
Set up your AWS account and the AWS CLI

Your app needs to talk to AWS programmatically. That requires an AWS account, an IAM user with access keys, and the AWS CLI installed on your machine.

  • Create a free AWS account by visiting https://aws.amazon.com/free/ and following the sign-up instructions.
  • You will need a credit card for verification, but this project stays within the AWS Free Tier.

What is the AWS Free Tier?

AWS gives new accounts 12 months of free usage for many services. For S3, that includes 5 GB of storage, 20,000 GET requests, and 2,000 PUT requests per month. This project uses a tiny fraction of those limits.

Once your account is ready, you need to create an IAM user. This gives your CLI a dedicated set of credentials instead of using your root account.

  • Open the IAM console at https://console.aws.amazon.com/iam/.
  • In the left navigation pane, click Users.
  • Click Create user.
  • Enter a user name (for example, gallery-cli-user) and click Next.
  • Under Permission options, select Attach policies directly.
  • Search for AmazonS3FullAccess and check the box next to it.
  • Click Next, review the summary, and click Create user.

Now create an access key for this user so the AWS CLI can authenticate.

  • Click on your new user's name to open their detail page.
  • Select the Security credentials tab.
  • Under Access keys, click Create access key.
  • Select Command Line Interface (CLI) as the use case, check the confirmation box, and click Next.
  • Click Create access key.
  • Click Download .csv file to save your access key ID and secret access key. Store this file somewhere safe. You will not be able to see the secret key again.
  • Click Done.

Keep your credentials safe

Never share your access key or secret key. Never paste them into code or commit them to a repository. The .csv file you downloaded is the only copy of the secret key.

Next, install the AWS CLI so you can configure those credentials on your machine.

  • Check whether the AWS CLI is already installed by running this in your terminal:
aws --version

✔️ I see aws-cli/2.x.x

The AWS CLI v2 is already installed. Skip ahead to the aws configure step below.

ⓧ I see aws-cli/1.x.x

You have an older version. You need version 2. Follow the instructions in the next tab to install the latest AWS CLI v2.

ⓧ Command not found

The AWS CLI is not installed yet. Install it by running the command for your operating system:

macOS

  • Install the AWS CLI v2 by running:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

After the install completes, close and reopen your terminal, then reactivate your virtual environment:

cd ~/Desktop/ai-image-gallery
source venv/bin/activate

Windows

  • Open a PowerShell window as Administrator by right-clicking the PowerShell icon and selecting Run as administrator.
  • Install the AWS CLI v2 by running:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex

After the install completes, close the Administrator PowerShell and open a regular PowerShell window. Navigate back to your project and reactivate:

cd ~/Desktop/ai-image-gallery
venv\Scripts\activate
  • Verify the installation by running:
aws --version

You should now see a version string starting with aws-cli/2. in the output.

Still seeing command not found?

Close your terminal completely and open a fresh one. Some installers need a new shell session to update your PATH.

help me get the AWS CLI working

Now configure the CLI with the credentials you downloaded.

  • Connect the AWS CLI to your account by running:
aws configure

The command prompts you for four values. Enter them one at a time:

  • For AWS Access Key ID: paste the access key ID from your downloaded .csv file.
  • For AWS Secret Access Key: paste the secret access key from the same file.
  • For Default region name: type us-east-1.
  • For Default output format: press Enter to accept the default.

Where do these credentials go?

The aws configure command stores your credentials in a file at ~/.aws/credentials (macOS/Linux) or %UserProfile%\.aws\credentials (Windows). The boto3 library in your Python app reads credentials from that same file automatically. No AWS keys need to go in your .env file.

Create your S3 bucket

Your images need a home in the cloud. An S3 bucket is a container that holds files (called objects) in AWS.

  • Open the Amazon S3 console at https://console.aws.amazon.com/s3/.
  • In the navigation bar at the top, click the region name and select US East (N. Virginia) us-east-1.
  • In the left navigation pane, click General purpose buckets.
  • Click Create bucket.
  • In the Bucket name field, enter a unique name like my-gallery-images- followed by your initials (for example, my-gallery-images-jd). Bucket names must be globally unique across all of AWS.
  • Scroll down past all the other settings. Leave everything at the defaults. Block Public Access should be checked on, which is correct for this project.
  • Click Create bucket at the bottom of the page.

You should see a green success banner at the top, and your new bucket appears in the bucket list.

Why keep Block Public Access on?

All new S3 buckets have Block Public Access turned on by default. This means nobody can access your images through a direct URL. You will use presigned URLs later to grant temporary, secure access to each image without making the bucket public.

Now go back to PyCharm and update your .env file with the bucket name you just created.

  • In .env, find the line that says:
S3_BUCKET_NAME=your-bucket-name-here
  • Replace your-bucket-name-here with the exact bucket name you entered in the console (for example, my-gallery-images-jd):
S3_BUCKET_NAME=my-gallery-images-jd
  • Save .env.

Time for the final check. Before you think about what you expect to see, ask yourself: what version of Flask did you install, and is the AWS CLI v2 working?

  • Verify Flask is installed by running:
python -m flask --version

You should see Flask's version number printed in the output.

  • Verify the AWS CLI is configured by running:
aws --version

You should see a version string starting with aws-cli/2. confirming the CLI is installed and ready.

Flask command not found?

Make sure (venv) appears in your terminal prompt. Flask was installed inside the virtual environment, so the command only works when the environment is active.

help me verify my Flask installation

Your environment is ready. You have Python packages installed, AWS credentials configured, and an S3 bucket waiting for images. Next up, you will build the Flask app and wire in Gemini to generate AI-powered captions for your photos.

Build the Upload Form with AI Descriptions

Your environment is ready with Python packages installed, AWS credentials configured, and an S3 bucket waiting for images. Now it's time to build the application that ties everything together.

In this step, you will create a two-step upload flow powered by Flask and Google Gemini. You pick an image, Gemini writes a caption for it, and you edit the caption on a preview page before saving it to a gallery.

In this step, get ready to:
  • Create a Flask app with routes for the gallery, image upload, and upload confirmation.
  • Connect Google Gemini to automatically generate image descriptions.
  • Build two HTML templates for the gallery page and the upload preview page.
Create the Flask app and gallery page

Every Flask app starts with imports, configuration, and at least one route. You will set up the full configuration now so every later chunk just adds a function or route.

  • In PyCharm, create a new file called app.py inside your project folder.
  • Add the following imports and configuration:
import json
import os
import uuid

import boto3
from botocore.exceptions import ClientError
from dotenv import load_dotenv
from flask import Flask, redirect, render_template, request, send_from_directory, url_for
from google import genai
from google.genai import types

# Load variables from .env file
load_dotenv()

app = Flask(__name__)

# Gallery metadata stored as a JSON file
METADATA_FILE = "gallery.json"
UPLOAD_FOLDER = "uploads"
# Read bucket name and region from environment variables
S3_BUCKET = os.environ.get("S3_BUCKET_NAME")
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")

# Create the uploads folder if it doesn't exist yet
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

# Initialize Gemini AI client and S3 client
gemini_client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
s3_client = boto3.client("s3", region_name=AWS_REGION)

What does this code do?

load_dotenv() reads your .env file so your API key and bucket name are available as environment variables.

METADATA_FILE points to a JSON file that stores each image's filename and description. UPLOAD_FOLDER is where temporary image files live before they are confirmed.

gemini_client connects to the Gemini API for image captioning. s3_client connects to AWS S3. You will use s3_client in a later step.

  • Below the s3_client line, add the helper functions, the gallery route, a route to serve uploaded images, and the app entry point:
# Load image metadata from the JSON file
def load_metadata():
    if os.path.exists(METADATA_FILE):
        with open(METADATA_FILE, "r") as f:
            return json.load(f)
    return []

# Save image metadata to the JSON file
def save_metadata(data):
    with open(METADATA_FILE, "w") as f:
        json.dump(data, f, indent=2)

# Home page shows all gallery images
@app.route("/")
def index():
    metadata = load_metadata()
    return render_template("index.html", images=metadata)

# Serve temp images from the uploads folder
@app.route("/uploads/<path:path>")
def uploaded_file(path):
    return send_from_directory(UPLOAD_FOLDER, path)

if __name__ == "__main__":
    app.run(debug=True)

What does this code do?

  • load_metadata() reads the gallery JSON file and returns a list of image entries. If the file does not exist yet, it returns an empty list.
  • save_metadata() writes updated image entries back to the JSON file.
  • index() is the home page route. It loads all image metadata and passes it to the gallery template.
  • uploaded_file() serves temporary images from the uploads/ folder so the preview page can display them in the browser.
  • Save app.py.
  • Start the Flask development server by running this command in your terminal:
python app.py

You should see output including Running on http://127.0.0.1:5000 in your terminal.

Server not starting?

  • Make sure your virtual environment is activated. You should see its name in parentheses at the start of your terminal prompt.
  • Check that your .env file is in the same folder as app.py. A missing GOOGLE_API_KEY causes the Gemini client to fail at startup.
  • If you see ModuleNotFoundError, confirm you installed packages from requirements.txt in the previous step.

Why isn't my Flask app starting?

Flask is running, but it needs an HTML template to display. Next, you will create the gallery page.

  • In the PyCharm Project panel, right-click your project folder.
  • Click New, then Directory.
  • Name the directory templates.
  • Right-click the new templates folder, then click New and File.
  • Name the file index.html.
  • Add the page header and upload form:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI Image Gallery</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .card img { height: 250px; object-fit: cover; }
    </style>
</head>
<body>
    <div class="container py-4">
        <h1 class="mb-4">AI Image Gallery</h1>

        <div class="card mb-4">
            <div class="card-body">
                <form action="/upload" method="post" enctype="multipart/form-data">
                    <div class="input-group">
                        <input type="file" name="image" class="form-control" accept="image/*" required>
                        <button type="submit" class="btn btn-primary">Upload</button>
                    </div>
                </form>
            </div>
        </div>
  • Directly below that code, add the gallery grid and the empty-state message:
        <div class="row g-4">
            {% for image in images %}
            <div class="col-md-4">
                <div class="card h-100">
                    <img src="/uploads/{{ image.key }}" class="card-img-top" alt="{{ image.filename }}">
                    <div class="card-body">
                        <p class="card-text">{{ image.description }}</p>
                        <small class="text-muted">{{ image.filename }}</small>
                    </div>
                </div>
            </div>
            {% endfor %}
        </div>

        {% if not images %}
        <div class="text-center text-muted mt-4">
            <p>No images yet. Upload one to get started!</p>
        </div>
        {% endif %}
    </div>
</body>
</html>

What does this template do?

The page uses Bootstrap for styling, loaded via a CDN link in the <head>.

The upload form sends a POST request to /upload with enctype="multipart/form-data" so Flask can receive the image file. The {% for image in images %} loop renders a card for each gallery entry.

  • Save templates/index.html.
  • Open your browser and go to http://127.0.0.1:5000.

You should see a page titled AI Image Gallery with a file picker and an Upload button. Below the form, the message "No images yet" appears because the gallery is empty.

Seeing an error page instead?

  • Make sure the templates folder is in the same directory as app.py. Flask looks for templates relative to the app file.
  • Confirm the file is named exactly index.html inside the templates folder. A typo in the filename causes a TemplateNotFound error.

Why is Flask showing a TemplateNotFound error?

Add AI-powered image descriptions

When a user uploads a photo, you want Gemini to examine the image and write a short caption. You will create the preview template first, then wire up the AI description function and upload route.

  • Right-click the templates folder in PyCharm, then click New and File.
  • Name the file preview.html.
  • Add the page header and image preview column:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Preview Upload</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .preview-img { max-height: 400px; object-fit: contain; }
    </style>
</head>
<body>
    <div class="container py-4">
        <h1 class="mb-4">Preview Upload</h1>

        <div class="row">
            <div class="col-md-6">
                <img src="/uploads/{{ temp_filename }}" class="img-fluid preview-img rounded" alt="Preview">
            </div>
  • Directly below that code, add the editable description form and closing tags:
            <div class="col-md-6">
                <form action="/confirm" method="post">
                    <input type="hidden" name="temp_filename" value="{{ temp_filename }}">
                    <input type="hidden" name="original_filename" value="{{ original_filename }}">
                    <input type="hidden" name="mime_type" value="{{ mime_type }}">

                    <div class="mb-3">
                        <label for="description" class="form-label">AI-Generated Description (edit as you like):</label>
                        <textarea name="description" id="description" class="form-control" rows="5">{{ description }}</textarea>
                    </div>

                    <button type="submit" class="btn btn-primary">Confirm Upload</button>
                    <a href="/" class="btn btn-outline-secondary ms-2">Cancel</a>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

What does the preview page do?

The left column displays the uploaded image from the temporary uploads/ folder. The right column shows Gemini's AI-generated description inside a <textarea> the user can freely edit.

Hidden form fields carry the temporary filename, original filename, and MIME type through to the confirm route. When the user clicks Confirm Upload, the form sends everything to /confirm as a POST request.

  • Save templates/preview.html.

Now you need the Python function that sends an image to Gemini and gets a caption back.

  • Switch back to app.py. Find the line @app.route("/") and add this function above it:
# Send image bytes to Gemini for an AI-generated caption
def generate_description(image_bytes, mime_type):
    response = gemini_client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
            "Describe this image in 2-3 sentences for a photo gallery caption.",
        ],
    )
    return response.text

What does this function do?

generate_description() sends the raw image bytes and their MIME type to Gemini's gemini-2.5-flash model. The types.Part.from_bytes() call packages the image data so the model can understand it visually.

The text prompt asks Gemini for a 2-3 sentence gallery caption. The model returns its response in response.text.

  • Save app.py. Your terminal should show Flask automatically restarting.

Next, you need the upload route that ties the file handling and AI description together.

  • In app.py, find the line if __name__ == "__main__": at the bottom. Add this route above it:
@app.route("/upload", methods=["POST"])
def upload():
    file = request.files.get("image")
    if not file or file.filename == "":
        return redirect(url_for("index"))

    # Read the image and save a temp copy for preview
    image_bytes = file.read()
    mime_type = file.content_type
    extension = os.path.splitext(file.filename)[1]
    temp_filename = f"{uuid.uuid4().hex}{extension}"
    temp_path = os.path.join(UPLOAD_FOLDER, temp_filename)

    with open(temp_path, "wb") as f:
        f.write(image_bytes)

    # Ask Gemini to describe the image
    description = generate_description(image_bytes, mime_type)

    return render_template(
        "preview.html",
        temp_filename=temp_filename,
        original_filename=file.filename,
        description=description,
        mime_type=mime_type,
    )

What does the upload route do?

  • It reads the uploaded file's bytes and content type from the POST request.
  • It generates a random filename using uuid.uuid4().hex to avoid name collisions, then saves the image to the uploads/ folder.
  • It calls generate_description() to get a Gemini caption, then renders the preview page with the image and description.
  • Save app.py.

Before you test, what do you think you will see after uploading an image?

  • Switch to your browser and refresh http://127.0.0.1:5000.
  • Click the file picker and select any image from your computer.
  • Click Upload.

You land on the preview page. On the left, the image you uploaded is displayed. On the right, Gemini's AI-generated description appears in an editable text area.

Seeing an error after uploading?

  • If you see a TemplateNotFound error for preview.html, check that the file is saved inside the templates folder with the exact name preview.html.
  • If you see an API error from Gemini, verify your GOOGLE_API_KEY in .env is correct. Restart the server with Ctrl+C and then run python app.py again after editing .env.
  • If the image preview shows a broken icon, make sure your uploaded_file route in app.py matches the code above.

Why is my image upload failing?

Complete the upload flow

The preview page shows the image and caption, but clicking Confirm Upload goes nowhere yet. You need a confirm route that saves the edited description and adds the image to the gallery.

  • In app.py, find the line if __name__ == "__main__": at the bottom. Add this route above it:
@app.route("/confirm", methods=["POST"])
def confirm():
    temp_filename = request.form.get("temp_filename")
    original_filename = request.form.get("original_filename")
    description = request.form.get("description")
    mime_type = request.form.get("mime_type")

    temp_path = os.path.join(UPLOAD_FOLDER, temp_filename)

    if not os.path.exists(temp_path):
        return redirect(url_for("index"))

    # Add this image's info to the gallery metadata
    metadata = load_metadata()
    metadata.append(
        {"key": temp_filename, "filename": original_filename, "description": description}
    )
    save_metadata(metadata)

    return redirect(url_for("index"))

What does the confirm route do?

The confirm route reads the edited description and file details from the hidden form fields. It checks that the temp file still exists, then appends a new entry to gallery.json with the image's key, original filename, and finalized description.

After saving, it redirects back to the home page, which now loads the updated metadata and displays the new image in the gallery grid.

  • Save app.py.

Before you test, what do you think will happen when you edit the AI description and click Confirm Upload?

  • Switch to your browser and refresh http://127.0.0.1:5000.
  • Upload a test image using the file picker.
  • On the preview page, edit the AI-generated description to something you prefer.
  • Click Confirm Upload.

You are redirected to the gallery page. Your image appears as a card with the caption you edited. The original filename displays below the description.

Image not showing in the gallery?

  • Make sure the uploaded_file route is in app.py. Without it, Flask cannot serve the images stored in the uploads/ folder.
  • Check that gallery.json was created in your project folder. Open it to verify it contains an entry for the image you just uploaded.
  • Restart the server by pressing Ctrl+C and running python app.py again.

Why is my confirmed image not appearing in the gallery?

✔️ Awesome, I've got everything!

Great. Double check you have saved both app.py and both template files.

ⓧ I'd like to double check the full code

Your app.py and templates are too long to display here in one block. Scroll up and confirm each section is present in the right order.

app.py should contain these sections from top to bottom:

  • Imports (json, os, uuid, boto3, dotenv, flask, genai, types).
  • load_dotenv(), app creation, constants (METADATA_FILE, UPLOAD_FOLDER, S3_BUCKET, AWS_REGION), and client initialization.
  • load_metadata() and save_metadata() helper functions.
  • generate_description() function.
  • index() route at /.
  • uploaded_file() route at /uploads/<path:path>.
  • upload() route at /upload.
  • confirm() route at /confirm.
  • if __name__ == "__main__": block at the very bottom.

templates/index.html should contain:

  • A Bootstrap-styled page with the title AI Image Gallery.
  • An upload form card with a file picker and Upload button.
  • A gallery grid using {% for image in images %} to display image cards.
  • An empty-state message: "No images yet. Upload one to get started!"

templates/preview.html should contain:

  • A two-column layout with the image preview on the left.
  • An editable <textarea> with the AI description on the right.
  • Hidden fields for temp_filename, original_filename, and mime_type.
  • Confirm Upload and Cancel buttons.

ⓧ I'd like to see app.py in full

Here is the first half of app.py, from imports through the generate_description function:

import json
import os
import uuid

import boto3
from botocore.exceptions import ClientError
from dotenv import load_dotenv
from flask import Flask, redirect, render_template, request, send_from_directory, url_for
from google import genai
from google.genai import types

# Load variables from .env file
load_dotenv()

app = Flask(__name__)

# Gallery metadata stored as a JSON file
METADATA_FILE = "gallery.json"
UPLOAD_FOLDER = "uploads"
# Read bucket name and region from environment variables
S3_BUCKET = os.environ.get("S3_BUCKET_NAME")
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")

# Create the uploads folder if it doesn't exist yet
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

# Initialize Gemini AI client and S3 client
gemini_client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
s3_client = boto3.client("s3", region_name=AWS_REGION)

Next come the helper functions and generate_description:

# Load image metadata from the JSON file
def load_metadata():
    if os.path.exists(METADATA_FILE):
        with open(METADATA_FILE, "r") as f:
            return json.load(f)
    return []

# Save image metadata to the JSON file
def save_metadata(data):
    with open(METADATA_FILE, "w") as f:
        json.dump(data, f, indent=2)

# Send image bytes to Gemini for an AI-generated caption
def generate_description(image_bytes, mime_type):
    response = gemini_client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
            "Describe this image in 2-3 sentences for a photo gallery caption.",
        ],
    )
    return response.text

Finally, the routes and entry point:

# Home page shows all gallery images
@app.route("/")
def index():
    metadata = load_metadata()
    return render_template("index.html", images=metadata)

# Serve temp images from the uploads folder
@app.route("/uploads/<path:path>")
def uploaded_file(path):
    return send_from_directory(UPLOAD_FOLDER, path)

@app.route("/upload", methods=["POST"])
def upload():
    file = request.files.get("image")
    if not file or file.filename == "":
        return redirect(url_for("index"))

    # Read the image and save a temp copy for preview
    image_bytes = file.read()
    mime_type = file.content_type
    extension = os.path.splitext(file.filename)[1]
    temp_filename = f"{uuid.uuid4().hex}{extension}"
    temp_path = os.path.join(UPLOAD_FOLDER, temp_filename)

    with open(temp_path, "wb") as f:
        f.write(image_bytes)
    # Ask Gemini to describe the image
    description = generate_description(image_bytes, mime_type)

    return render_template(
        "preview.html",
        temp_filename=temp_filename,
        original_filename=file.filename,
        description=description,
        mime_type=mime_type,
    )

@app.route("/confirm", methods=["POST"])
def confirm():
    temp_filename = request.form.get("temp_filename")
    original_filename = request.form.get("original_filename")
    description = request.form.get("description")
    mime_type = request.form.get("mime_type")

    temp_path = os.path.join(UPLOAD_FOLDER, temp_filename)

    if not os.path.exists(temp_path):
        return redirect(url_for("index"))

    # Add this image's info to the gallery metadata
    metadata = load_metadata()
    metadata.append(
        {"key": temp_filename, "filename": original_filename, "description": description}
    )
    save_metadata(metadata)

    return redirect(url_for("index"))

if __name__ == "__main__":
    app.run(debug=True)

Your gallery app is working locally. You can upload images, let Gemini caption them, tweak the descriptions, and see everything in the gallery. Next up, you will move the images out of local storage and into your S3 bucket so they have a permanent, scalable home.

Store Images in Amazon S3

In Step 2, you built a full upload flow where you pick an image, preview it with an AI-generated caption, edit the text, and confirm. The gallery page displays everything with captions from a JSON metadata file.

There is a problem with this setup. Images live in a local uploads/ folder on your machine. If you restart the app, deploy it somewhere else, or lose that folder, every image is gone. In this step, you will move image storage to Amazon S3 so your photos have a permanent, scalable home in the cloud.

In this step, get ready to:
  • Update the confirm route to upload images to S3 using boto3.
  • Update the gallery to load images from S3 URLs.
  • Discover why private S3 buckets break direct image access.
Upload images to S3 from the confirm route

Right now, the confirm() route saves the temporary filename as the metadata key and leaves images sitting in the local uploads/ folder. You need it to upload the image bytes to your S3 bucket instead, then clean up the local file.

  • In app.py, scroll down to the confirm() function.
  • Find this code after the if not os.path.exists(temp_path): guard clause:
    metadata = load_metadata()
    metadata.append(
        {"key": temp_filename, "filename": original_filename, "description": description}
    )
    save_metadata(metadata)

    return redirect(url_for("index"))
  • Replace that entire block with the following code, which uploads to S3 and cleans up the temp file:
    # Generate a unique S3 key for the image
    extension = os.path.splitext(original_filename)[1]
    key = f"gallery/{uuid.uuid4().hex}{extension}"

    # Read the image bytes from the temp file
    with open(temp_path, "rb") as f:
        image_bytes = f.read()

    # Upload the image to S3
    s3_client.put_object(
        Bucket=S3_BUCKET,
        Key=key,
        Body=image_bytes,
        ContentType=mime_type,
    )

    # Remove the temp file now that it's in S3
    os.remove(temp_path)

    # Save metadata with the S3 key
    metadata = load_metadata()
    metadata.append(
        {"key": key, "filename": original_filename, "description": description}
    )
    save_metadata(metadata)

    return redirect(url_for("index"))

What does this code do?

This replacement does four things the old code did not:

  • It builds an S3 object key with a gallery/ prefix and a random UUID, so every image has a unique path inside your bucket.
  • It reads the temp file back into memory and sends those bytes to S3 with put_object(). The ContentType parameter tells S3 what kind of file it is (e.g. image/jpeg).
  • It deletes the local temp file with os.remove() because the image now lives in S3.
  • It saves the S3 key (key) in the metadata instead of the old local filename.

Why put_object instead of upload_fileobj?

Since you already have the image bytes in memory (read from the temp file), put_object() is the most direct way to send them to S3. upload_fileobj() is designed for streaming large files from file-like objects with automatic multipart transfers. For gallery-sized images, put_object() is simpler and does the job.

  • Save app.py. The Flask dev server auto-reloads when you save because debug=True is set.
  • Switch to your browser and open http://127.0.0.1:5000.
  • Upload a new test image, edit the AI description on the preview page, and click Confirm Upload.

The gallery page loads, but the image you just uploaded appears broken. That is expected for now because the template still points at local file paths. The important check is whether the file actually made it to S3.

  • Open the AWS S3 Console in a new browser tab.
  • Click on your bucket name (e.g. my-gallery-images-<your-initials>).
  • Open the gallery/ folder inside the bucket.
  • You should see an object with a UUID-based filename matching the image you just uploaded.

Don't see your image in S3?

  • Make sure your S3_BUCKET_NAME in .env matches the exact name of the bucket you created in the AWS Console. Bucket names are case-sensitive.
  • Check that your AWS CLI credentials are still configured. Run aws sts get-caller-identity in a terminal to confirm your credentials are valid.
  • Look for error messages in the terminal where Flask is running. A NoCredentialsError means boto3 cannot find your AWS credentials.

Still stuck? Help me debug why my boto3 put_object call is not uploading my image to S3.

Update the gallery to load images from S3 URLs

Your images are in S3, but the gallery template still tries to load them from local /uploads/ paths. You need to generate S3 URLs for each image and update the template to use them.

  • In app.py, scroll up to the index() function.
  • Find this line:
    metadata = load_metadata()
    return render_template("index.html", images=metadata)
  • Add these two lines between metadata = load_metadata() and return render_template(...), so the function looks like this:
@app.route("/")
def index():
    metadata = load_metadata()
    # Build a direct S3 URL for each image
    for item in metadata:
        item["url"] = f"https://{S3_BUCKET}.s3.amazonaws.com/{item['key']}"
    return render_template("index.html", images=metadata)

What does this URL do?

This constructs the public virtual-hosted URL for each S3 object. The format is https://<bucket>.s3.amazonaws.com/<key>. If your bucket is named my-gallery-images-abc and the key is gallery/f7a2b3.jpg, the URL would be https://my-gallery-images-abc.s3.amazonaws.com/gallery/f7a2b3.jpg.

  • Save app.py.

Now you need to update the template to use this new url field instead of the old local path.

  • Open templates/index.html.
  • Find this line inside the {% for image in images %} loop:
<img src="/uploads/{{ image.key }}" class="card-img-top" alt="{{ image.filename }}">
  • Replace src="/uploads/{{ image.key }}" with src="{{ image.url }}", so the line looks like this:
<img src="{{ image.url }}" class="card-img-top" alt="{{ image.filename }}">
  • Save templates/index.html.

Before you refresh the gallery, take a moment to predict. The images are in S3 and the template now points at S3 URLs. Do you think the images will load in the browser?

  • Refresh http://127.0.0.1:5000 in your browser.

The gallery shows your captions, but every image is a broken image icon. The files are in S3 (you confirmed that in the console), yet the browser cannot load them.

Why are the images broken?

When you created your S3 bucket in Step 1, you left Block Public Access turned on. That is the correct, secure default. It means nobody on the internet can read your objects using a direct URL.

When your browser tries to load https://<bucket>.s3.amazonaws.com/<key>, S3 returns a 403 Forbidden because the request has no credentials. The image bytes are there, but S3 refuses to hand them over to an anonymous browser request.

This is a good thing. You do not want your bucket open to the public. In the next step, you will learn how presigned URLs solve this by generating temporary, signed links that grant read access for a limited time without changing your bucket's security settings.

Seeing something other than broken images?

  • If you see no images at all (not even broken icons), check that your gallery.json file has entries in it. Open it in PyCharm and confirm it contains metadata for your uploaded image.
  • If images from Step 2 still display correctly, those are old entries pointing at local files. Upload a new image to test the S3 flow. Only new uploads go through the updated confirm() route.
  • If you see an error in the Flask terminal instead of a page loading, check that the index() function's for loop is indented correctly inside the function body.

Need help? Help me figure out why my S3 gallery images are not displaying the way I expected.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app.py and templates/index.html.

ⓧ I'd like to double check the full code

Both files are over 30 lines, so here is a checklist of changes to verify. Scroll through each file and confirm every item is present.

In app.py, check these sections in order:

  • The index() function has a for loop that builds an S3 URL for each metadata item, between metadata = load_metadata() and return render_template(...).
  • The confirm() function generates a key with f"gallery/{uuid.uuid4().hex}{extension}".
  • The confirm() function reads the temp file bytes, calls s3_client.put_object(), then calls os.remove(temp_path).
  • The metadata append uses key (the S3 key), not temp_filename.

In templates/index.html:

  • The <img> tag inside the {% for image in images %} loop uses src="{{ image.url }}" instead of the old src="/uploads/{{ image.key }}".

Your images are safely stored in S3, but they are invisible to the browser because the bucket is locked down. In the next step, you will use presigned URLs to generate temporary, signed links that let the browser display your private images without opening the bucket to the public.

Serve Images Securely with Presigned URLs

Your images made it to S3, but the gallery is showing broken image icons. That is because your bucket has Block Public Access turned on, which means direct URLs to S3 objects get rejected.

Presigned URLs solve this without making your bucket public. They are temporary, signed links that include a cryptographic signature and an expiration timestamp. Your bucket stays locked down, but the browser can display each image for a limited window of time.

In this step, get ready to:
  • Add a presigned URL helper function that generates temporary signed links for private S3 images.
  • Build a delete route that removes images from both S3 and the gallery.
  • Test the full upload, preview, gallery, and delete flow end to end.
Add a presigned URL helper function

Right now your index() route builds a direct URL to each S3 object. S3 rejects those requests because Block Public Access is on. A presigned URL includes a cryptographic signature and expiration in the query string, telling S3 to allow the request for a limited window.

  • In app.py, find the generate_description() function.
  • Add the following new function below it:
def get_presigned_url(key):
    # Generate a temporary signed URL for a private S3 object
    try:
        url = s3_client.generate_presigned_url(
            "get_object",
            Params={"Bucket": S3_BUCKET, "Key": key},
            ExpiresIn=3600,
        )
        return url
    except ClientError:
        return None

What does this code do?

  • generate_presigned_url("get_object", ...) asks boto3 to create a temporary signed URL that allows a GET request on the specified S3 object.
  • ExpiresIn=3600 sets the link to expire after 3600 seconds (1 hour). After that, the URL stops working and a new one must be generated.
  • The try/except catches any ClientError (like a missing bucket or bad credentials) and returns None instead of crashing the app.

Now you need to connect this helper to the gallery route so each image gets a fresh presigned URL.

  • In app.py, scroll down to the index() function.
  • Find this line:
        item["url"] = f"https://{S3_BUCKET}.s3.amazonaws.com/{item['key']}"
  • Replace that entire line with this:
        item["url"] = get_presigned_url(item["key"])
  • Save app.py.

Before you refresh the gallery, what do you think will happen now that the route uses presigned URLs instead of direct S3 links?

  • If Flask is still running, it should auto-reload with debug=True. Refresh http://127.0.0.1:5000 in your browser.

Your images now load correctly in the gallery. The broken image icons are gone.

Images still broken?

  • Make sure you saved app.py and that Flask reloaded (check the terminal for a "Restarting with stat" message).
  • Check that you replaced the entire direct URL line and did not leave the old f"https://..." string in place.
  • Verify your S3_BUCKET_NAME in .env matches your actual bucket name in the AWS Console.

Why aren't my S3 images loading even with presigned URLs?

Add a delete route

You can upload and view images, but there is no way to remove them. A delete route needs to do two things: remove the image from S3 and remove its entry from the metadata file.

  • In app.py, find the confirm() function.
  • Add the following route below it, above the if __name__ == "__main__": block:
@app.route("/delete/<path:key>")
def delete(key):
    # Remove the image from S3
    try:
        s3_client.delete_object(Bucket=S3_BUCKET, Key=key)
    except ClientError:
        pass

    # Remove the entry from the metadata file
    metadata = load_metadata()
    metadata = [item for item in metadata if item["key"] != key]
    save_metadata(metadata)

    return redirect(url_for("index"))

What does this code do?

  • The route /delete/<path:key> accepts the S3 object key as part of the URL. The path: converter allows slashes in the key (like gallery/abc123.jpg).
  • s3_client.delete_object() removes the image file from your S3 bucket.
  • The list comprehension filters out the deleted image's entry from gallery.json, then saves the updated list back to disk.

The route exists in Python, but the gallery template does not have a button that links to it yet. Let's add one.

  • Open templates/index.html.
  • Find the closing </div> tag for card-body (the one right after the <small> tag showing the filename).
  • Add the following card footer directly below that closing </div>, still inside the <div class="card h-100">:
                    <div class="card-footer">
                        <a href="/delete/{{ image.key }}" class="btn btn-sm btn-outline-danger">Delete</a>
                    </div>

What does this code do?

This adds a Bootstrap-styled red "Delete" button in the footer of each image card. Clicking it sends a GET request to your new /delete/<key> route, which removes the image from S3 and the metadata file, then redirects back to the gallery.

  • Save both app.py and templates/index.html.
  • Refresh your browser. Each image card now has a red Delete button in the footer.
  • Click Delete on one of your images. The image disappears from the gallery.

Delete button not showing up?

  • Make sure you saved templates/index.html. Unsaved template changes will not appear even if Flask reloads.
  • Check that the <div class="card-footer"> is placed after the card-body closing </div> but still inside the <div class="card h-100">.

Why isn't my delete button appearing in the gallery cards?

✔️ Awesome, I've got everything!

Great. Double check you have saved both app.py and templates/index.html.

ⓧ I'd like to double check the full code

Your app.py is now its final version. Scroll through the file and confirm these sections are present, in order:

  • Imports, app setup, and constants (METADATA_FILE, UPLOAD_FOLDER, S3_BUCKET, AWS_REGION).
  • load_metadata() and save_metadata() helper functions.
  • generate_description() helper function.
  • get_presigned_url() helper function (the one you just added).
  • index() route using get_presigned_url() (not the old direct URL).
  • upload() and confirm() routes.
  • delete() route (the one you just added).
  • if __name__ == "__main__": block at the very end.

For templates/index.html, confirm each image card has a <div class="card-footer"> with a delete link, placed after the card-body closing tag.

Test the full flow

Time to run through the entire upload-to-delete lifecycle and confirm every piece works together.

Before you start, what do you expect to happen when you upload a new image, edit the caption, and confirm? Do you think the gallery will display the image correctly this time?

  • Open http://127.0.0.1:5000 in your browser.
  • Upload a new image using the upload form.
  • On the preview page, edit the AI-generated description in the text area.
  • Click Confirm Upload.
  • The gallery shows your new image with the caption you edited.
  • Right-click on any image in the gallery and select Copy image address (or Copy Image Link on some browsers).
  • Paste the URL somewhere. Notice it contains query parameters like Signature and Expires. That is the presigned URL at work.
  • Click Delete on one of your images. The image disappears from the gallery.
  • Open the AWS S3 Console and check your bucket. The deleted image's object is gone.

Why do presigned URLs expire?

A presigned URL that never expired would be just as insecure as a public bucket. Anyone who got hold of the URL could access the image forever.

By setting ExpiresIn=3600, each URL works for 1 hour. Your gallery generates a fresh URL on every page load, so visitors always get a working link. If someone copies a URL and tries to use it tomorrow, S3 will reject the request.

Your AI image gallery is fully operational. Images upload to S3, Gemini writes the captions, presigned URLs keep everything private, and the delete button cleans up both sides. Next up, see if you can take your gallery even further with a Secret Mission.

Secret mission

Add Search to Your Gallery

Your gallery is growing, but finding a specific image means scrolling through every card. In this challenge, you will add a search feature that filters images by their AI-generated captions so you can find exactly what you are looking for.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS S3 Free Tier storage, so there are no ongoing costs for a small number of images within the first 12 months of your AWS account.

Resources you used:

  • S3 bucket with uploaded gallery images.
  • IAM user access keys configured via aws configure.
  • Local project files (app.py, templates, .env, gallery.json, uploads/ folder, virtual environment).
  • AWS CLI v2 (installed during this project).

Keep everything running

No action needed. Choose this if you plan to keep building on this gallery or want to experiment with more uploads.

  • Your S3 bucket stays within the AWS Free Tier for small numbers of images (5 GB storage, 2,000 PUT and 20,000 GET requests per month for 12 months after sign-up).
  • Your local project files remain on your Desktop.
  • The AWS CLI stays installed on your machine for future AWS projects.

Pause - I'll come back to this later

There is nothing to pause for this project. S3 storage charges are negligible for a few images within the Free Tier.

  • Stop the Flask development server if it is still running by pressing Ctrl+C in your terminal.
  • Your S3 images, local files, and AWS CLI all stay in place for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

Empty and delete your S3 bucket
  • Open the Amazon S3 console at https://console.aws.amazon.com/s3/.
  • In the left navigation pane, choose General purpose buckets.
  • Select the radio button next to your gallery bucket, then choose Empty.
  • Type permanently delete to confirm, then choose Empty.
  • Return to the bucket list, select your bucket again, then choose Delete.
  • Type your bucket name to confirm, then choose Delete bucket.
Delete your IAM access keys
  • Open the IAM console at https://console.aws.amazon.com/iam/.
  • In the navigation pane, choose Users.
  • Choose the user name you created for this project.
  • Select the Security credentials tab.
  • In the Access keys section, choose Actions, then choose Deactivate. Confirm by choosing Deactivate.
  • Choose Actions again, then choose Delete. Enter the access key ID to confirm, then choose Delete.
Delete your local project files
  • Delete your project folder from your Desktop by running the command for your operating system.

macOS

rm -rf ~/Desktop/ai-image-gallery

Windows

Remove-Item -Recurse -Force ~\Desktop\ai-image-gallery
Uninstall the AWS CLI (optional)

If you no longer need the AWS CLI for other projects, you can remove it.

  • Open Programs and Features in the Windows Control Panel.
  • Select AWS Command Line Interface, then choose Uninstall.
  • Confirm the uninstallation.

What about the .aws credentials folder?

The AWS CLI stores your credentials in a .aws folder in your user directory. If you uninstall the CLI and no longer need these credentials, you can delete that folder. Be careful: other AWS SDKs (including boto3) also read from this folder. Only delete it if you are sure no other tools need it.

That's a Wrap!

That's a Wrap!

Nice work! You just built a full-stack image gallery that combines Flask, Google Gemini, and Amazon S3 into a working web app with AI-powered captions and secure cloud storage.

You've learned how to:

  • Built a two-step upload flow where you pick an image, review an editable AI-generated caption on a preview page, then confirm the upload.
  • Created an S3 bucket, uploaded images programmatically with boto3, and saw firsthand why private buckets break direct image URLs.
  • Fixed the broken images by implementing presigned URLs that grant temporary, secure access to private S3 objects without ever making the bucket public.
  • Secret Mission: Added keyword search to filter the gallery by matching search terms against your AI-generated captions.

Ready to quiz yourself?