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-dotenvCreate 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-1And .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.pyVisit 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-limiterfrom 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:appFour 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 = 0Then:
fly secrets set SNAPSHARP_API_KEY=sk_live_... S3_BUCKET=... S3_REGION=eu-west-1
fly redis create
fly deployAuto-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