Build a fast image processing server with Go and Libvips
Go can expose a small HTTP API while libvips performs native image operations. Here we use govips to resize, crop, watermark and convert images through one shared request handler.
This is a bounded loopback demonstration, not an authenticated public upload service. Native image processing needs isolation and operational limits even when requests and dimensions are checked.
Why Libvips?
Libvips evaluates image pipelines on demand and can reduce intermediate work. Actual speed and memory use depend on the operation, codecs, image dimensions and concurrency. Benchmark your workload rather than assuming a universal speedup.
Setting up your Go environment
Prerequisites
Use a maintained Go release, a C compiler, pkg-config and libvips. This example was tested on Linux with Go 1.26.8, govips v2.18.0, and libvips 8.14.1 and 8.18.6. The pinned module declares Go 1.25.0 as its minimum; that minimum is separate from the versions tested here.
Installation
On Ubuntu 24.04, install the native development library:
sudo apt-get install --no-install-recommends build-essential pkg-config libvips-dev
Install Go separately using the Go installation guide.
On macOS, brew install vips pkg-config installs the native libraries. Follow
govips' platform instructions for compiler-specific settings.
In Bash, create a new project and enter it only after dependency installation succeeds. Retain both module files. If installation fails, inspect the partially created directory before retrying:
mkdir image-api &&
(
cd image-api &&
go mod init example.com/image-api &&
go get github.com/davidbyttow/govips/v2/vips@v2.18.0
) &&
cd image-api
Building a basic image processing server
Put the complete program in main.go. It accepts JPEG and PNG input and emits a still image. Formats
and operation parameters are explicit. Both the image and optional watermark go through the same
size and decoder checks.
package main
import (
"bytes"
"context"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/davidbyttow/govips/v2/vips"
)
const maxBytes = 8 << 20
const maxPixels = 4_000_000
var slots = make(chan struct{}, 2)
var invalid = errors.New("unsupported image request")
func number(values url.Values, key string, fallback, minimum, maximum int) (int, error) {
text := values.Get(key)
if text == "" {
if _, present := values[key]; present { return 0, invalid }
return fallback, nil
}
if len(text) > 4 { return 0, invalid }
for _, character := range text {
if character < '0' || character > '9' { return 0, invalid }
}
value, err := strconv.Atoi(text)
if err != nil || value < minimum || value > maximum { return 0, invalid }
return value, nil
}
func loadImage(header *multipart.FileHeader) (*vips.ImageRef, error) {
file, err := header.Open()
if err != nil { return nil, err }
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil || len(data) == 0 || len(data) > maxBytes { return nil, invalid }
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "jpeg" && format != "png") ||
config.Width < 1 || config.Height < 1 ||
config.Width > 4096 || config.Height > 4096 ||
config.Width > maxPixels/config.Height {
return nil, invalid
}
source, err := vips.NewImageFromBuffer(data)
if err != nil { return nil, err }
if source.Pages() > 1 {
source.Close()
return nil, invalid
}
if err := source.AutoRotate(); err != nil {
source.Close()
return nil, err
}
// Normalize 16-bit PNG samples before compositing or using an 8-bit white background.
if err := source.ToColorSpace(vips.InterpretationSRGB); err != nil {
source.Close()
return nil, err
}
return source, nil
}
func transform(source *vips.ImageRef, operation string, values url.Values,
form *multipart.Form) error {
switch operation {
case "resize", "crop":
width, err := number(values, "width", 256, 1, 2048)
if err != nil { return err }
height, err := number(values, "height", 256, 1, 2048)
if err != nil { return err }
if operation == "resize" {
// govips expects scale factors, not output pixel dimensions.
return source.ResizeWithVScale(float64(width)/float64(source.Width()),
float64(height)/float64(source.Height()), vips.KernelLanczos3)
}
left, err := number(values, "left", 0, 0, 4096)
if err != nil { return err }
top, err := number(values, "top", 0, 0, 4096)
if err != nil { return err }
if width > source.Width() || height > source.Height() ||
left > source.Width()-width || top > source.Height()-height {
return invalid
}
return source.ExtractArea(left, top, width, height)
case "watermark":
overlay, err := loadImage(form.File["watermark"][0])
if err != nil { return err }
defer overlay.Close()
if overlay.Width() > source.Width() || overlay.Height() > source.Height() {
return invalid
}
return source.Composite(overlay, vips.BlendModeOver,
source.Width()-overlay.Width(), source.Height()-overlay.Height())
case "convert":
return nil
}
return invalid
}
func encode(source *vips.ImageRef, format string) ([]byte, error) {
// Older libvips exporters can retain EXIF despite the strip option.
if err := source.RemoveMetadata(); err != nil { return nil, err }
var output []byte
var err error
switch format {
case "jpeg":
if source.HasAlpha() {
if err := source.Flatten(&vips.Color{R: 255, G: 255, B: 255}); err != nil {
return nil, err
}
}
params := vips.NewJpegExportParams()
params.StripMetadata = true
output, _, err = source.ExportJpeg(params)
case "png":
params := vips.NewPngExportParams()
params.StripMetadata = true
output, _, err = source.ExportPng(params)
case "webp":
params := vips.NewWebpExportParams()
params.StripMetadata = true
output, _, err = source.ExportWebp(params)
default:
return nil, invalid
}
return output, err
}
func processImage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
select {
case slots <- struct{}{}:
defer func() { <-slots }()
default:
http.Error(w, "Image processor is busy.", http.StatusServiceUnavailable)
return
}
values, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
http.Error(w, "Invalid parameters.", http.StatusBadRequest)
return
}
operation := values.Get("operation")
allowed := map[string]bool{"operation": true, "format": true}
switch operation {
case "resize", "crop":
allowed["width"], allowed["height"] = true, true
if operation == "crop" { allowed["left"], allowed["top"] = true, true }
case "convert", "watermark":
default:
http.Error(w, "Unsupported operation.", http.StatusBadRequest)
return
}
for key, entries := range values {
if !allowed[key] || len(entries) != 1 {
http.Error(w, "Invalid parameters.", http.StatusBadRequest)
return
}
}
format := values.Get("format")
if _, present := values["format"]; !present { format = "png" }
if format != "png" && format != "jpeg" && format != "webp" {
http.Error(w, "Unsupported format.", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes+64*1024)
err = r.ParseMultipartForm(1 << 20)
if r.MultipartForm != nil { defer r.MultipartForm.RemoveAll() }
// Multipart parsing stops at its final boundary; count any remaining request bytes too.
if err == nil { _, err = io.Copy(io.Discard, r.Body) }
if err != nil {
code := http.StatusBadRequest
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { code = http.StatusRequestEntityTooLarge }
http.Error(w, "Invalid or oversized upload.", code)
return
}
form := r.MultipartForm
expected := 1
if operation == "watermark" { expected = 2 }
if len(form.Value) != 0 || len(form.File) != expected || len(form.File["file"]) != 1 ||
(operation == "watermark" && len(form.File["watermark"]) != 1) {
http.Error(w, "Provide the required image files.", http.StatusBadRequest)
return
}
source, err := loadImage(form.File["file"][0])
if err != nil {
http.Error(w, "Unsupported image.", http.StatusBadRequest)
return
}
defer source.Close()
if err := transform(source, operation, values, form); err != nil {
http.Error(w, "Image operation could not be completed.", http.StatusBadRequest)
return
}
output, err := encode(source, format)
if err != nil {
http.Error(w, "Image encoding failed.", http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "image/"+format)
w.Write(output)
}
func run() error {
if err := vips.Startup(&vips.Config{ConcurrencyLevel: 1, MaxCacheSize: 0}); err != nil {
return err
}
defer vips.Shutdown()
address := os.Getenv("LISTEN_ADDR")
if address == "" { address = "127.0.0.1:8080" }
listener, err := net.Listen("tcp", address)
if err != nil { return err }
mux := http.NewServeMux()
mux.HandleFunc("POST /process", processImage)
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5*time.Second,
ReadTimeout: 15*time.Second, WriteTimeout: 30*time.Second, IdleTimeout: 30*time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
served := make(chan error, 1)
go func() { served <- server.Serve(listener) }()
select {
case err := <-served:
if errors.Is(err, http.ErrServerClosed) { return nil }
return err
case <-ctx.Done():
deadline, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
if err := server.Shutdown(deadline); err != nil {
// Never shut libvips down while a native handler is still running.
log.Print("Image server shutdown deadline exceeded.")
os.Exit(1)
}
return nil
}
}
func main() {
if err := run(); err != nil {
log.Printf("Image server failed: %v", err)
os.Exit(1)
}
}
Run go run . from image-api, and leave that terminal open. The default address is
127.0.0.1:8080; set LISTEN_ADDR to use another address if the port is occupied. Each file is limited
to 8 MiB, 4,096 pixels per side, and four million pixels. Both files and multipart overhead together
must fit the 8 MiB + 64 KiB request limit, including bytes after the multipart terminator. Multipart
data may spill to temporary disk; the deferred RemoveAll() cleans it on successful and failed
requests. Abrupt process termination can leave temporary files behind.
Implementing common image operations
In a second terminal, use a directory containing your own photo.jpg, photo.png, and logo.png.
The crop example needs a photo at least 110 × 90 pixels after orientation. Output is a still image;
this API is not an animation preservation workflow. These cURL commands overwrite their named
outputs. With --fail-with-body, an HTTP error can replace an output with error text; check the exit
status before opening it as an image. A missing local input can fail before an existing output is
replaced.
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=resize&width=320&height=180' -o resized.png
Resizing stretches to the requested dimensions. Width and height become horizontal and vertical
scale factors; passing pixel counts directly to ResizeWithVScale would create a very large image.
Cropping an image
Crop coordinates apply after EXIF orientation:
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=crop&left=10&top=10&width=100&height=80' -o crop.png
The crop must fit entirely inside the input. Subtraction-based checks avoid overflowing a sum of untrusted offsets and sizes.
Adding a watermark
Supply a JPEG or transparent PNG smaller than the base image. It is placed at the bottom right:
curl --fail-with-body -F 'file=@photo.jpg' -F 'watermark=@logo.png' \
'http://127.0.0.1:8080/process?operation=watermark' -o watermarked.png
Converting image formats
JPEG output composites transparency onto white. PNG and WebP can retain it. The loader converts
16-bit PNG samples to 8-bit sRGB before processing: the white background value 255 must use the
same range as the image. See libvips’ color conversion
and alpha flattening documentation.
JPEG and WebP exports use lossy defaults; this is not a lossless archival workflow.
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Optimizing performance and memory usage
Tuning Libvips configuration
The example sets one native thread per operation and disables the operation cache. Tune these settings with real measurements. Resize and crop accept output sides up to 2,048 pixels. Resize can therefore produce 2,048 × 2,048 pixels, slightly more than the four-million-pixel input limit. Conversion and watermarking retain the base image dimensions, which can exceed 2,048 on one side. These checks and the two-request limit do not bound every native allocation or the encoded output size.
Implementing a worker pool
The bounded channel is a semaphore, not an unbounded job queue. A third simultaneous request receives 503 immediately. This makes backpressure explicit without a second set of image-loading and export functions. Larger deployments need fleet-wide limits and appropriately isolated workers.
Monitoring resource usage
Measure process RSS as well as Go heap usage: native libvips allocations are outside the Go heap. Also measure temporary disk, rejected requests, decoder failures and latency. HTTP deadlines do not cancel native processing. Use process isolation to enforce hard CPU and memory limits.
Malformed headers and decoder failures produce errors, but successful decoding is not an integrity check. When accepting unfamiliar files, inspect the full output, including its edges. The program removes input EXIF before export because a strip option alone can retain it with older libvips exporters. govips retains some technical metadata internally, including color profiles. This example is not a comprehensive privacy scrubber or a workflow for accurate reproduction of arbitrary embedded color profiles.
Deploying with docker
Use matching Debian releases for the build and runtime libraries. Save this as Dockerfile in
image-api, beside go.mod, go.sum and main.go:
FROM golang:1.26-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=1 go build -o /image-api .
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends libvips42 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /image-api /usr/local/bin/image-api
USER 65534:65534
ENV LISTEN_ADDR=0.0.0.0:8080
EXPOSE 8080
CMD ["/usr/local/bin/image-api"]
Stop the local go run server before publishing the container on the same port. From image-api,
build the image and publish only to loopback for a local test:
docker build -t image-api . &&
docker run --rm --stop-timeout=45 --memory=512m --cpus=2 -p 127.0.0.1:8080:8080 image-api
On SIGINT or SIGTERM, the program stops accepting connections and waits up to 35 seconds for active handlers. The container’s 45-second stop grace leaves time for that shutdown; Docker’s default Linux grace is 10 seconds. If a native operation outlasts the application deadline, the process exits without normal cleanup.
Before a public deployment, add TLS, authentication and authorization, proxy body limits, abuse controls and a reviewed decoder policy. The container example demonstrates packaging, not complete tenant isolation. For managed processing, explore Transloadit's image processing service.
