The smallest real app that proves the point

A couple of friends wanted to see how I host side projects on DigitalOcean. Instead of explaining, I built them a 120-line Flask app that uploads images to Spaces (DigitalOcean's S3-compatible storage) and displays them in a grid. The whole thing deploys on git push via App Platform — no Dockerfile, no cluster management.

This isn't another to-do app. It touches the two things you actually care about: does my code run without babysitting a server, and where do user files go? The answer is yes, and Spaces.

Why App Platform matters now

When Heroku killed its free tier in November 2022, a generation of developers who'd never thought about hosting suddenly had to. Many ended up in raw VPS territory (owning the server, patches, firewall) or full cloud (where deploying a hobby app involves IAM roles and a YAML file longer than the app).

App Platform is DigitalOcean's answer to that gap. It's the closest thing to old-Heroku's "just push it" feeling. Connect a repo, it detects the language, builds and runs it. No Dockerfile, no cluster. Pricing is flat and legible — you know the bill before the month starts.

The app in one file

Everything lives in app.py. Config comes from environment variables — nothing secret touches the repo:

import os
import uuid
import boto3
from botocore.client import Config
from flask import Flask, redirect, render_template, request, url_for

SPACES_KEY = os.environ.get("SPACES_KEY")
SPACES_SECRET = os.environ.get("SPACES_SECRET")
SPACES_REGION = os.environ.get("SPACES_REGION", "nyc3")
SPACES_BUCKET = os.environ.get("SPACES_BUCKET")
SPACES_ENDPOINT = os.environ.get(
    "SPACES_ENDPOINT", f"https://{SPACES_REGION}.digitaloceanspaces.com"
)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024  # 8 MB

Notice the use of boto3, the AWS SDK. Spaces speaks the S3 API, so all your existing S3 knowledge transfers. The only change is the endpoint:

def get_client():
    session = boto3.session.Session()
    return session.client(
        "s3",
        region_name=SPACES_REGION,
        endpoint_url=SPACES_ENDPOINT,
        aws_access_key_id=SPACES_KEY,
        aws_secret_access_key=SPACES_SECRET,
        config=Config(signature_version="s3v4"),
    )

The gotcha that cost me 20 minutes

SPACES_ENDPOINT must be the region endpoint (e.g., https://nyc3.digitaloceanspaces.com), not the bucket-prefixed URL DigitalOcean shows you (e.g., https://my-bucket.nyc3.digitaloceanspaces.com). boto3 adds the bucket itself. Using the bucket-prefixed URL gives cryptic signature errors that look like an auth problem but aren't.

The rest is plumbing

List images from the bucket and build public URLs:

def public_url(key):
    return f"{SPACES_ENDPOINT.rstrip('/')}/{SPACES_BUCKET}/{key}"

def list_images(limit=60):
    client = get_client()
    response = client.list_objects_v2(Bucket=SPACES_BUCKET, Prefix="uploads/")
    objects = response.get("Contents", [])
    objects.sort(key=lambda o: o["LastModified"], reverse=True)
    return [public_url(o["Key"]) for o in objects[:limit]]

Routes: a gallery, an upload handler, and a health check:

@app.route("/")
def index():
    images = list_images() if all([SPACES_KEY, SPACES_SECRET, SPACES_BUCKET]) else []
    return render_template("index.html", images=images)

@app.route("/upload", methods=["POST"])
def upload():
    file = request.files.get("image")
    if not file or "." not in file.filename:
        return redirect(url_for("index"))
    ext = file.filename.rsplit(".", 1)[1].lower()
    if ext not in ALLOWED_EXTENSIONS:
        return redirect(url_for("index"))
    key = f"uploads/{uuid.uuid4().hex}.{ext}"
    client = get_client()
    client.put_object(
        Bucket=SPACES_BUCKET,
        Key=key,
        Body=file,
        ACL="public-read",
        ContentType=file.mimetype,
    )
    return redirect(url_for("index"))

@app.route("/health")
def health():
    return {"status": "ok"}, 200

One line worth pausing on: ACL="public-read". Buckets are private by default, so without this the upload succeeds but images 404 in the browser.

Getting Spaces ready

In the DigitalOcean panel: Spaces Object Storage → Create a Spaces Bucket. Pick a region, give it a globally-unique name. Then go to API → Spaces Keys → Generate New Key. You get an access key and a secret — the secret shows once. Copy it now.

Run locally first

export SPACES_KEY="..."
export SPACES_SECRET="..."
export SPACES_REGION="atl1"
export SPACES_BUCKET="your-bucket"
pip install -r requirements.txt
python app.py

Open http://localhost:8080, upload something. If it shows up, keys and bucket are good.

Deploying: the part that sells it

Push to GitHub, then in the panel: App Platform → Create App → point it at your repo. App Platform sees Python and just builds it. No Dockerfile. It picks up the start command from a one-line Procfile:

web: gunicorn --worker-tmp-dir /dev/shm --bind 0.0.0.0:$PORT app:app

Add your four env vars under the component's settings, tick encrypt on key and secret, deploy. You get a *.ondigitalocean.app URL. Every push to main redeploys automatically.

Is it for you?

Reach for App Platform + Spaces if: you want to ship a web app without thinking about servers, you like git push as your deploy button, and you'd rather not write Kubernetes manifests for a side project. The Spaces-is-just-S3 thing means zero new storage API to learn.

Look elsewhere if: you need fine-grained infra control, exotic runtimes, or you're already deep in another cloud's ecosystem.

For side projects, demos, internal tools, and "I just need this online today" — the effort-to-running-app ratio is hard to beat.

Next steps: fork the repo at github.com/oceanforge/spaces-gallery, add thumbnails with Pillow, enable the Spaces CDN, or attach a Managed Database for upload metadata. The code is MIT licensed and has good first issues if you want to contribute.