All posts

Image uploads in Go with presigned URLs and on-demand variants

A Go walkthrough with dreep-go: server-side uploads, presigned browser uploads, error handling and responsive variants, using only the standard library.

· 7 min read

Go is a natural fit for the backend of an upload flow: the standard library has a good HTTP server, multipart parsing and crypto, and since Go 1.22 the router understands methods and path parameters. What it does not have is an image pipeline. This post builds the upload side of a photo feature in Go with dreep-go, the Dreep Go SDK, and then serves responsive variants from the stored originals.

dreep-go has no dependencies outside the standard library and needs Go 1.22 or later. Every method takes a context.Context first, and errors decode into *dreep.Error with helper functions.

Install and wire up

Shell
go get github.com/IndigoSoftwares21/dreep-go

export DREEP_API_KEY=drp_live_...

The API key is a full-project credential. It stays in the server's environment and never reaches the browser. Create the client once at start-up and pass it to your handlers.

Gomain.go
package main

import (
    "log"
    "net/http"
    "os"

    "github.com/IndigoSoftwares21/dreep-go"
)

func main() {
    client, err := dreep.New(os.Getenv("DREEP_API_KEY"))
    if err != nil {
        log.Fatal(err)
    }

    store := NewPendingStore() // your table of upload ids per user

    mux := http.NewServeMux()
    mux.HandleFunc("POST /photos", uploadPhoto(client))                          // small files, server side
    mux.HandleFunc("POST /uploads/presign", presign(client, store))              // browser uploads
    mux.HandleFunc("POST /uploads/{id}/confirm", confirm(client, store))

    log.Fatal(http.ListenAndServe(":8080", withSession(mux)))
}

userFromContext and withSession stand in for your own authentication. Every handler below starts by finding the user, because deciding who may upload is your job, not the storage service's.

Small files: upload from the server

When files are small and already arrive at your server, for example a profile photo from a classic form post, forward them with Upload. It streams the multipart body to the Dreep API rather than buffering it in memory.

Goupload.go
// uploadPhoto accepts a multipart form from your own frontend and forwards it.
// Fine for small images; large files should use presigned uploads instead.
func uploadPhoto(client *dreep.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user := userFromContext(r.Context())
        r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10 MB

        file, header, err := r.FormFile("photo")
        if err != nil {
            http.Error(w, "missing photo", http.StatusBadRequest)
            return
        }
        defer file.Close()

        asset, err := client.Upload(r.Context(), dreep.UploadOptions{
            File:        file,
            Filename:    header.Filename,
            KnownSize:   header.Size, // avoids chunked encoding
            Destination: dreep.Destination{Folder: "users/" + user.ID + "/photos"},
            // Normalise on the way in: the stored file is the 2048px WebP
            Transform: &dreep.Transform{Width: 2048, Fit: dreep.FitInside, Format: dreep.FormatWebP},
        })
        if err != nil {
            writeDreepError(w, err)
            return
        }

        json.NewEncoder(w).Encode(asset)
    }
}

The Transform here runs before storage, so the stored asset is the normalised 2048 px WebP and the phone's 12-megapixel original is not kept. Leave Transform out if you need the original. Streaming uploads are never retried automatically, because the body cannot be replayed, so a failed upload can never create a duplicate.

Large files and browsers: presigned uploads

For anything large, or anything coming straight from a browser or mobile app, do not proxy the bytes. Your server presigns, the client PUTs the file to a single-use URL that is valid for 10 minutes, and your server confirms.

  1. Client

    POST /uploads/presign

  2. Go server

    PresignUpload

  3. Client

    PUT bytes to uploadUrl

  4. Go server

    ConfirmUpload

Gopresign.go
var imageTypes = map[string]bool{
    "image/jpeg": true, "image/png": true, "image/webp": true, "image/heic": true,
}

func presign(client *dreep.Client, store *PendingStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user := userFromContext(r.Context())

        var req struct {
            Filename    string `json:"filename"`
            ContentType string `json:"contentType"`
            SizeBytes   int64  `json:"sizeBytes"`
            ContentHash string `json:"contentHash"` // optional SHA-256 hex
        }
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
            !imageTypes[req.ContentType] || req.SizeBytes <= 0 || req.SizeBytes > 25<<20 {
            http.Error(w, "unsupported file", http.StatusBadRequest)
            return
        }

        upload, err := client.PresignUpload(r.Context(), dreep.PresignOptions{
            ContentType: req.ContentType,
            SizeBytes:   req.SizeBytes,
            Filename:    req.Filename,
            ContentHash: req.ContentHash,
            Destination: dreep.Destination{Folder: "users/" + user.ID + "/photos"},
        })
        if err != nil {
            writeDreepError(w, err)
            return
        }

        store.Save(upload.ID, user.ID)
        json.NewEncoder(w).Encode(map[string]any{
            "id":            upload.ID,
            "uploadUrl":     upload.UploadURL, // empty when alreadyExists
            "alreadyExists": upload.AlreadyExists,
        })
    }
}

func confirm(client *dreep.Client, store *PendingStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user := userFromContext(r.Context())
        id := r.PathValue("id")

        if !store.BelongsTo(id, user.ID) {
            http.NotFound(w, r)
            return
        }

        asset, err := client.ConfirmUpload(r.Context(), id, nil)
        if err != nil {
            writeDreepError(w, err)
            return
        }

        store.Delete(id)
        json.NewEncoder(w).Encode(asset)
    }
}

The checks that matter are all on your side of the presign call: the content type is on your allow-list, the size is under your limit (Dreep's own ceiling is 500 MB per file), the folder comes from the authenticated user and the upload id is recorded against that user so nobody else can confirm it. Dreep enforces that the PUT's Content-Type and Content-Length match what you presigned.

If the client sends a SHA-256 contentHash and Dreep already stores that content, AlreadyExists is true and UploadURL is empty: the client skips the PUT and calls confirm directly.

Server-side batches

When your own server holds a large file, for example during a migration, UploadPresigned does presign, PUT and confirm in one call. It skips the PUT when ContentHash matches existing content, and retries confirm while the bytes settle.

Gobatch.go
// Server-side batch import of large files: presign, PUT and confirm in one call.
f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()

info, err := f.Stat()
if err != nil {
    return err
}

asset, err := client.UploadPresigned(ctx, dreep.DirectUploadOptions{
    PresignOptions: dreep.PresignOptions{
        ContentType: "image/jpeg",
        SizeBytes:   info.Size(),
        Filename:    filepath.Base(path),
        Destination: dreep.Destination{Folder: "archive/2026"},
    },
    File: f,
})

Handling errors

Map Dreep's errors onto responses your client understands. The interesting cases are a confirm that arrives before the bytes (409), a validation failure (400) and a plan limit (402), which carries the feature that ran out:

Goerrors.go
func writeDreepError(w http.ResponseWriter, err error) {
    switch {
    case dreep.IsConflict(err):
        // Confirm called before the PUT finished. The client should retry.
        http.Error(w, "upload not finished", http.StatusConflict)
    case dreep.IsInvalidRequest(err):
        http.Error(w, "invalid upload", http.StatusBadRequest)
    case dreep.IsPaymentRequired(err):
        if key, ok := dreep.FeatureLimit(err); ok {
            log.Printf("dreep plan limit reached: %s", key)
        }
        http.Error(w, "uploads are temporarily unavailable", http.StatusServiceUnavailable)
    default:
        log.Printf("dreep: %v", err)
        http.Error(w, "upload failed", http.StatusBadGateway)
    }
}

Requests with replayable bodies are retried on 429 and 5xx, twice by default with exponential backoff that respects Retry-After. Tune it with dreep.WithMaxRetries and dreep.WithRetryBaseDelay, or pass 0 to disable retries if your own job runner already retries.

Serving variants on demand

Store the asset id (and URL) in your database and build variants when rendering. client.URL is pure string work, with no network call and no API key, so it is safe in a hot template path. When Format is set, the SDK puts it in the path as an extension.

Govariants.go
// No network call: URL only builds a string.
thumb := client.URL(asset.ID, &dreep.Transform{
    Width: 384, Height: 384, Fit: dreep.FitCover,
    Gravity: dreep.GravityAttention, Format: dreep.FormatWebP,
})
// https://cdn.dreep.cloud/api/v1/fetch/<id>.webp?fit=cover&gravity=attention&height=384&width=384

// A srcset for html/template, using widths from Dreep's breakpoint ladder
func srcset(client *dreep.Client, id string) template.Srcset {
    widths := []int{384, 640, 828, 1080, 1440, 1920}
    parts := make([]string, 0, len(widths))
    for _, wd := range widths {
        u := client.URL(id, &dreep.Transform{Width: wd, Format: dreep.FormatWebP})
        parts = append(parts, fmt.Sprintf("%s %dw", u, wd))
    }
    return template.Srcset(strings.Join(parts, ", "))
}

Each distinct variant is generated on its first request and cached. Dreep rounds widths and heights up to a fixed ladder of sizes, so choosing srcset widths from that ladder (384, 640, 828, 1080, 1440, 1920) means each width is exactly one cached file and you never pay for bytes you did not ask for.

Each transformed request counts against your plan's image transformations: 100 a month on Free, 10,000 on Starter ($2.99) and 50,000 on Pro ($5.99). The normalising upload above counts once, at upload. If you have a handful of recurring sizes, save them as presets with client.CreatePreset and reference them with Transform{Preset: "card_v1"}, so the recipe lives in one place.

Cleaning up

Presigned uploads that are never confirmed stay pending and are excluded from listings, but your own pending-upload table will grow. Expire rows older than the 10-minute upload window plus a margin, for example with a periodic job that deletes anything older than an hour.

When a user deletes a photo, call client.DeleteMedia(ctx, assetID) after removing your own record. Deleting removes the original and every cached variant, frees the storage against your plan, and makes existing URLs stop resolving. If you need an undo window, soft-delete in your database first and run the Dreep delete from a job a few days later.

To reconcile your database with what is stored, client.ListAllMedia walks every page of a folder and calls your function for each asset. That is useful after a migration or an incident, to find assets your database no longer references.

HEIC and other phone formats

iPhones save photos as HEIC by default, which most browsers on other platforms cannot display. Dreep accepts HEIC and HEIF uploads and converts on delivery, so a Format: dreep.FormatWebP variant renders everywhere. If you never want HEIC in storage, normalise on upload with a WebP or JPEG Transform, as in the multipart handler above.

Testing

Because the client is configured with options, tests can point it at an httptest.Server with dreep.WithAPIBaseURL and assert on the requests your handlers make, with no network access. The SDK's own tests use the same approach.

Trade-offs

  • Forwarding multipart through your server is simplest but costs your server bandwidth and connection time. Use it for small files only.
  • Presigned uploads take three requests and need a pending-upload table, but your servers never touch the bytes.
  • Upload transforms save storage and bandwidth but discard the original. Keep originals for anything that may be shown in a layout you have not designed yet.
  • dreep-go signs URLs for signed folders with SignedURL, which at the time of writing covers untransformed links. See signed URLs for private images and video for details.

The SDK is on the Go SDK page, the upload protocol in the uploading guide, and every image parameter in the image transformations docs.

Related

Try the examples against your own project

The Free plan includes 1 GB of storage, 100 image and 20 video transformations a month, which is enough to run every snippet in these posts.