a bucket is just a folder that speaks HTTP
Every image and video on this blog lives in MinIO, an S3-compatible object store you can run yourself instead of paying Amazon for it. I'd heard "S3" used as basically a synonym for "cloud storage" for years without ever needing to know what it actually was. Turns out the mental model is almost insultingly simple: a bucket is a named container, an object is a file inside it with a key (basically a path), and you talk to the whole thing over plain HTTP with an access key and secret instead of, say, a database connection string. Docker compose spins up two buckets the first time it boots:
minio-init:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
mc mb --ignore-existing local/media &&
mc mb --ignore-existing local/avatars &&
mc anonymous set download local/media &&
mc anonymous set download local/avatars
"media and avatars, both set to public download so anyone can view an image URL directly, but nobody can list or write to them without the actual credentials. The blog and auth services are the only two things that ever hold those.
what actually gets checked before a file gets saved
Uploading isn't just "take the bytes and hand them to MinIO." The blog service sniffs the first 512 bytes of whatever gets sent to figure out its real content type, instead of trusting the filename extension or whatever the browser happened to claim:
head := make([]byte, 512)
n, err := io.ReadFull(part, head)
...
head = head[:n]
contentType, ext, ok := media.DetectType(head, part.FileName())
if !ok {
writeFieldError(w, r, http.StatusUnprocessableEntity, "unsupported_media_type", "file type not allowed", "file")
return
}
maxBytes := s.MaxImageBytes
if strings.HasPrefix(contentType, "video/") {
maxBytes = s.MaxVideoBytes
}Rename a .exe to photo.png and the extension check would happily believe you, but sniffing the actual byte signature at the front of the file won't. Images and videos get different size ceilings too, an image tops out at 10MB, a video gets a full gigabyte, which made a lot more sense once I actually thought about what a phone video clip weighs next to a photo.
avatars get the small, strict treatment
Profile pictures go through a much tighter cap than a post's media, 2 mebibytes on the auth service, which sounds mean until you remember most phone cameras produce photos five to ten times that size without even trying. Rather than just rejecting anything too big and making someone go find an image editor, the profile form tries to shrink it for you first, entirely in the browser, before it's ever sent:
/** Longest-side target for a re-encoded avatar. */
export const AVATAR_MAX_SIDE = 512;
/** Mirrors the auth service's maxAvatarBytes (2 MiB). */
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
export function targetDimensions(
width: number,
height: number,
maxSide: number = AVATAR_MAX_SIDE
): { width: number; height: number } {
if (width <= 0 || height <= 0) return { width, height };
const longest = Math.max(width, height);
if (longest <= maxSide) return { width, height };
const scale = maxSide / longest;
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale))
};
}It's a progressive enhancement, not a guarantee. If canvas isn't available, or the browser has JavaScript turned off entirely, the original file just goes up untouched and the server's own cap is the real backstop either way, same as it always was. The downscale is purely there so the common case, someone's default phone photo, never even hits that wall in the first place.
the bug: uploads dying at exactly 3MB
For a while, any upload past a certain size just failed, no useful error, just a dead request. Kept happening at almost the same size every time, somewhere around 3MB, which was suspicious on its own since none of my actual limits in the Go code were anywhere near that low.
Spent an embarrassing amount of time in blog's upload handler, convinced I'd misconfigured MaxImageBytes or the multipart reader somehow, adding logging that never once fired, because the request was never reaching blog at all. The actual limit lived one layer further out, in Caddy, which sits in front of everything as the reverse proxy:
# before: one blanket cap for the whole admin API, including media uploads
handle /api/admin/* {
request_body {
max_size 3MB
}
reverse_proxy frontend:3000
}Caddy caps the request body before it ever reaches the frontend container, let alone blog. A 3MB photo hit that wall and got rejected right there, an empty, generic 413 with none of the nice JSON error blog would otherwise have sent, because blog never got the chance to run at all. I stared at the wrong service's code for most of an evening.
Fixed it by giving media uploads their own, much larger, path match. Caddy sorts these blocks by how specific the path is, not by file order, so a more specific match for /api/admin/media* wins over the general admin block even sitting above it in the file:
# Large-upload paths: admin media and avatar upload need enough room for
# legitimate uploads (service-level caps stay at avatar 2MiB, media 10MB/1GB).
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}
# Rest of the admin API (posts, comments, users, reports, etc.) needs
# headroom above the 2MB default for admin post bodies (service-level
# cap is 4MB), but far below the media block above it.
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}blaming the wrong service, and where the real caps actually live
The lesson that stuck: a request this size passes through three separate layers before any of my own application code even runs, Caddy's max_size, then SvelteKit's own BODY_SIZE_LIMIT, then finally blog's own MaxImageBytes/MaxVideoBytes check. Any one of those can reject the request, and which error you actually see depends entirely on which layer said no first. The frontend has a small piece of code specifically to stop that confusion from leaking out to whoever's actually uploading:
// A genuine network failure (DNS, connection refused) carries no `status`
// anywhere in the chain, which is how the two are told apart without
// SvelteKit exporting the internal error class to `instanceof`-check.
function bodyTooLargeError(thrown: unknown): { status?: unknown; message?: unknown } | undefined {
const direct = errorLike(thrown);
if (direct?.status === 413) return direct;
const cause = direct ? errorLike((direct as { cause?: unknown }).cause) : undefined;
if (cause?.status === 413) return cause;
return undefined;
}Whichever layer actually rejected the file, the person uploading only ever sees "the upload is too large," never "service unavailable," which is what a raw dropped connection would otherwise look like from their side. Took a genuinely broken upload path, and an evening staring at the wrong logs, to make me care about that distinction at all.
