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.


