Menu
flaskpythontutorialscreenshot-apimicroservice

Flask Screenshot Microservice Tutorial — Build a Thumbnail API in 10 Minutes

SnapSharp Team·April 26, 2026·4 min read

If you need a tiny screenshot service that other apps can call — say, a thumbnail generator for a marketing site, a "site preview" widget, or an internal dashboard tool — Flask is hard to beat. Two files, no plugins, no ORM. Pair it with SnapSharp and you skip the Chromium installation entirely. The result is a microservice you can deploy on a free Fly.io machine or a $5 droplet.

This tutorial builds that microservice from scratch. By the end you'll have a Flask app that accepts a URL, returns a thumbnail, caches results in Redis, and uploads to S3 for long-term storage.

Prerequisites

  • Python 3.11+ and pip.
  • Redis running locally (or any Redis URL).
  • A free SnapSharp API key from snapsharp.dev/sign-up.
  • (Optional) An S3 bucket or Cloudflare R2 bucket for persistent storage.

Step 1: scaffold the project

mkdir flask-snapsharp && cd flask-snapsharp
python -m venv venv && source venv/bin/activate
pip install flask snapsharp redis boto3 gunicorn python-dotenv

Create a .env file:

SNAPSHARP_API_KEY=sk_live_YOUR_API_KEY
REDIS_URL=redis://localhost:6379/0
S3_BUCKET=my-thumbnail-bucket
S3_REGION=eu-west-1

And .gitignore it. Always.

Step 2: a minimal Flask app

app.py:

import hashlib
import os
from flask import Flask, request, abort, Response
from snapsharp import SnapSharp, SnapSharpError
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
snap = SnapSharp(api_key=os.environ['SNAPSHARP_API_KEY'])


@app.get('/health')
def health():
    return {'ok': True}


@app.get('/thumb')
def thumb():
    target = request.args.get('url')
    if not target:
        abort(400, 'url required')

    try:
        image = snap.screenshot(
            target,
            width=1280,
            height=720,
            format='png',
            block_ads=True,
        )
    except SnapSharpError as exc:
        app.logger.error('SnapSharp failure for %s: %s', target, exc)
        abort(502, 'capture failed')

    return Response(
        image,
        mimetype='image/png',
        headers={
            'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
        },
    )


if __name__ == '__main__':
    app.run(debug=True, port=5000)

Run it:

python app.py

Visit http://localhost:5000/thumb?url=https://github.com in a browser. You should see a screenshot. Two endpoints, fewer than 40 lines of code, and you have a working thumbnail service.

Step 3: add Redis caching

The naive version above hits SnapSharp on every request. For a public-facing service, that's expensive and slow. Cache in Redis keyed by a hash of the URL + dimensions:

import hashlib
import json
from datetime import timedelta
import redis

redis_client = redis.Redis.from_url(os.environ['REDIS_URL'], decode_responses=False)
CACHE_TTL = int(timedelta(days=1).total_seconds())


def cache_key(url: str, opts: dict) -> str:
    payload = json.dumps({'url': url, **opts}, sort_keys=True)
    digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
    return f'thumb:{digest}'


@app.get('/thumb')
def thumb():
    target = request.args.get('url')
    if not target:
        abort(400, 'url required')

    opts = {'width': 1280, 'height': 720, 'format': 'png', 'block_ads': True}
    key = cache_key(target, opts)

    cached = redis_client.get(key)
    if cached:
        app.logger.info('cache HIT for %s', target)
        return Response(cached, mimetype='image/png', headers={
            'X-Cache': 'HIT',
            'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
        })

    try:
        image = snap.screenshot(target, **opts)
    except SnapSharpError as exc:
        app.logger.error('SnapSharp failure for %s: %s', target, exc)
        abort(502, 'capture failed')

    redis_client.setex(key, CACHE_TTL, image)
    return Response(image, mimetype='image/png', headers={
        'X-Cache': 'MISS',
        'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
    })

The X-Cache header is for your own debugging. Watch the ratio in production logs — if you're under 80% HIT, your URLs aren't deterministic enough.

Step 4: long-term storage in S3

Redis is cheap but volatile. For images you want to keep around (e.g., embedded in marketing pages), upload to S3 or Cloudflare R2. The boto3 SDK works for both:

import boto3
from botocore.config import Config

s3 = boto3.client(
    's3',
    region_name=os.environ['S3_REGION'],
    config=Config(retries={'max_attempts': 3, 'mode': 'standard'}),
)
S3_BUCKET = os.environ['S3_BUCKET']


def upload_to_s3(image: bytes, key: str) -> str:
    s3.put_object(
        Bucket=S3_BUCKET,
        Key=key,
        Body=image,
        ContentType='image/png',
        CacheControl='public, max-age=31536000, immutable',
    )
    return f'https://{S3_BUCKET}.s3.amazonaws.com/{key}'


@app.post('/persist')
def persist():
    data = request.get_json()
    target = data.get('url')
    if not target:
        abort(400, 'url required')

    try:
        image = snap.screenshot(target, width=1280, height=720, format='png')
    except SnapSharpError as exc:
        abort(502, 'capture failed')

    digest = hashlib.sha256(target.encode()).hexdigest()[:16]
    key = f'thumbs/{digest}.png'
    public_url = upload_to_s3(image, key)

    return {'url': public_url, 'cached': False}

A POST to /persist with {"url": "https://..."} returns a JSON response with a permanent CDN-backed URL. Use it for "Generate Thumbnail" buttons in your CMS.

Step 5: rate limiting

Public endpoints need rate limits or a single bot will drain your SnapSharp quota in minutes. flask-limiter is the standard choice:

pip install flask-limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    get_remote_address,
    app=app,
    storage_uri=os.environ['REDIS_URL'],
    default_limits=[],
)


@app.get('/thumb')
@limiter.limit('30 per minute')
def thumb():
    ...


@app.post('/persist')
@limiter.limit('5 per minute')
def persist():
    ...

The storage_uri makes the limiter Redis-backed, which means it works correctly behind a load balancer with multiple workers.

Step 6: input validation

Don't trust user-supplied URLs blindly. SnapSharp blocks internal IPs server-side, but rejecting early is good practice:

from urllib.parse import urlparse


PRIVATE_HOST_PATTERNS = ('localhost', '127.', '10.', '192.168.', '169.254.')


def is_public_http_url(input_str: str) -> bool:
    try:
        u = urlparse(input_str)
        if u.scheme not in ('http', 'https'):
            return False
        host = u.hostname or ''
        if any(host.startswith(p) for p in PRIVATE_HOST_PATTERNS):
            return False
        if host == 'metadata.google.internal':
            return False
        return True
    except Exception:
        return False


@app.get('/thumb')
@limiter.limit('30 per minute')
def thumb():
    target = request.args.get('url', '')
    if not is_public_http_url(target):
        abort(400, 'invalid url')
    ...

Step 7: production deployment

gunicorn

The Flask dev server isn't safe for production. Use gunicorn with a few workers:

gunicorn -w 4 -b 0.0.0.0:8000 --timeout 120 app:app

Four workers, two-minute request timeout (some screenshots take a while). On a 1-vCPU droplet, four workers is plenty — they spend most of their time waiting on SnapSharp's HTTP response.

Docker

Dockerfile:

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "--timeout", "120", "app:app"]

docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - SNAPSHARP_API_KEY=${SNAPSHARP_API_KEY}
      - REDIS_URL=redis://redis:6379/0
      - S3_BUCKET=${S3_BUCKET}
      - S3_REGION=${S3_REGION}
    depends_on:
      - redis
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  redis-data:

Run with docker compose up -d. Add nginx in front for TLS. Done.

Fly.io

The simplest deploy. Create fly.toml:

app = "my-thumbnail-service"
primary_region = "fra"

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0

Then:

fly secrets set SNAPSHARP_API_KEY=sk_live_... S3_BUCKET=... S3_REGION=eu-west-1
fly redis create
fly deploy

Auto-stop on idle keeps the bill near zero for low-traffic services.

Common pitfalls

Pitfall 1: caching by URL only. If you support both PNG and WebP, both width=1280 and width=2560, your cache key must include all of those. Hashing the full options dict (as we did above) is the right move.

Pitfall 2: leaking exceptions. Default Flask error pages reveal stack traces in development. Make sure app.config['ENV'] = 'production' in deploy. Or use werkzeug.exceptions.HTTPException with custom handlers.

Pitfall 3: not setting Content-Type correctly for WebP/JPEG. If you let users choose format, set the mimetype dynamically: f'image/{fmt}'.

Pitfall 4: storing API key in Docker image. Never bake SNAPSHARP_API_KEY into a Dockerfile. Always pass it at runtime via env vars or secrets.

Pitfall 5: Redis without TTL. If you set keys without setex, they'll live forever. Even on small services that fills the cache. Always use TTL.

Final code

Two files:

  • app.py — Flask app with caching, rate limiting, S3 upload (~120 lines).
  • Dockerfile + docker-compose.yml — deployment config.

The whole microservice fits in 150 lines and a $5/month server.

Conclusion

Flask + SnapSharp is the lightest path to a thumbnail microservice. There's no Chromium binary, no Playwright runtime, no headless browser concurrency to manage. You write a small HTTP shim, add Redis for caching and S3 for persistence, and deploy. It scales to thousands of req/min on a single small VM.

Next steps: read about webhooks for async notifications, explore the FastAPI background task tutorial, or compare the Django + Celery pipeline.


Related: Python tutorial · Pricing · Why headless Chrome crashes

Flask Screenshot Microservice Tutorial — Build a Thumbnail API in 10 Minutes — SnapSharp Blog