You know that moment when a project starts simple but ends up forcing you to learn more about FFmpeg? Yeah, welcome to elcompresso, a media compression tool that does one thing: accept a file, compress it, upload to S3, and returns a download link (easy enough right?).
This is how i built it.
Backend
elcompresso is a REST API that:
- accepts video, audio, or image uploads
- compresses videos/audio with FFmpeg and images with Go’s native
imagepackage - stores the result in AWS S3
- generates a presigned URL for secure downloads
The entire backend is written in Golang & Gin.
Architecture: Domain, Adapters, Services, and Handlers
The backend follows hexagonal architecture, separating concerns into layers:
backend/
├── cmd/
│ ├── main.go (entry point, dependency wiring)
│ └── api/api.go (route setup)
├── internal/
│ ├── adapter/ (external services: compression, storage)
│ ├── service/ (business logic layer)
│ ├── port/http/ (HTTP handlers)
│ └── domain/ (interfaces and value objects)
└── pkg/
├── env/ (environment config)
└── response/ (JSON response wrappers)
Wiring It All Together
In backend/cmd/main.go, we initialize the entire dependency graph:
func main() {
environmentVariables := env.LoadEnvironment()
cfg, err := awsConfig.LoadDefaultConfig(context.TODO())
if err != nil {
panic(err)
}
s3Client := s3.NewFromConfig(cfg)
adapterDependencies := adapter.AdapterDependencies{
EnvironmentVariables: environmentVariables,
Compressor: &compress.CompressorDependencies{},
StorageClient: s3Client,
}
adapters := adapter.NewAdapter(adapterDependencies)
serviceDependencies := service.ServiceDependencies{
Adapter: adapters,
}
services := service.NewService(serviceDependencies)
r := api.API(services, environmentVariables)
r.Engine.Run(environmentVariables.Port)
}Three lines of actual instantiation. Everything else flows from those.
The API Layer
Routes are defined in backend/cmd/api/api.go. We set CORS to allow any origin (reserve your comments mr. security expert), configure file upload memory limits, and expose compression endpoints:
func API(services *service.Services, environment *env.EnvironmentVariables) *Server {
r := &Server{
Service: services,
Engine: gin.Default(),
Environment: environment,
}
config := cors.DefaultConfig()
config.AllowAllOrigins = true
config.AllowMethods = []string{"POST", "GET", "PUT", "OPTIONS"}
r.Engine.Use(cors.New(config))
r.Engine.Static("/downloads", "tmp")
r.Engine.GET("/health", ...)
api := r.Engine.Group("/api/v1")
{
r.fileCompressRoutes(api)
r.fileUploadRoutes(api)
}
return r
}Compression Routes
Three POST endpoints at /api/v1/file-compress/{video,audio,image}. Each accepts a multipart form with:
file: the media to compressquality: a 1-100 quality hint (interpreted differently per format)
Handlers: The Request Pipeline
In backend/internal/port/http/handler/compress.go, the CompressHandler is initialized with dependencies and processes requests. Let's walk through the video handler:
func (h CompressHandler) CompressVideo(c *gin.Context) {
var fData FormData
if err := c.ShouldBind(&fData); err != nil {
response.NewErrorResponse(fmt.Errorf("invalid form data: %v", err.Error())).Send(c)
return
}
if fData.File.Size > 500<<20 {
response.NewErrorResponse(fmt.Errorf("file too large: max 500MB")).Send(c)
return
}
f, err := fData.File.Open()
if err != nil {
response.NewErrorResponse(fmt.Errorf("failed to open file: %v", err)).Send(c)
return
}
defer f.Close()
fmtedFileName := strings.ReplaceAll(fData.File.Filename, " ", "_")
req := compress.CompressionRequest{
Input: f,
FileName: fmtedFileName,
FileType: "video",
Quality: fData.Quality,
}
res, err := h.adapter.Compressor.Video.Compress(req)
if err != nil {
response.NewErrorResponse(fmt.Errorf("failed to compress file: %v", err)).Send(c)
return
}
key, err := h.adapter.Storage.Upload(c.Request.Context(), uuid.New().String()+"_"+fData.File.Filename, res.Output)
if err != nil {
response.NewErrorResponse(fmt.Errorf("upload failed: %w", err)).Send(c)
return
}
dUrl, err := h.adapter.Storage.GenerateDownloadURL(c.Request.Context(), key, 24*time.Hour)
response.NewSuccessResponse("success", gin.H{
"original_size": fData.File.Size,
"compressed_size": res.CompressedSize,
"download_link": dUrl,
}, nil).Send(c)
}The pattern is identical for audio and image; essentially just parse, validate size, compress, upload, generate download URL, return JSON.
The Compression Adapters
Here's where the magic happens. Each media type has its own compressor implementing the compress.Interface:
type Interface interface {
Compress(req CompressionRequest) (*CompressionResult, error)
Supports(fileType FileType, extension string) bool
}Video Compression
backend/internal/adapter/compress/video/video.go uses FFmpeg to handle .mp4, .mkv, .avi, .mov, .webm, .flv.
func (v *VideoCompressor) Compress(req compress.CompressionRequest) (*compress.CompressionResult, error) {
ext := filepath.Ext(req.FileName)
inputFile, err := os.CreateTemp("", "input-*"+ext)
if err != nil {
return nil, err
}
defer os.Remove(inputFile.Name())
if _, err := io.Copy(inputFile, req.Input); err != nil {
return nil, err
}
inputFile.Close()
outputFile, err := os.CreateTemp("", "output-*"+ext)
if err != nil {
return nil, err
}
defer os.Remove(outputFile.Name())
outputFile.Close()
args := v.ffmpegArgs(inputFile.Name(), outputFile.Name(), ext)
cmd := exec.Command("ffmpeg", args...)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg failed: %w", err)
}
compressed, err := os.Open(outputFile.Name())
if err != nil {
return nil, err
}
info, _ := compressed.Stat()
return &compress.CompressionResult{
Output: compressed,
CompressedSize: info.Size(),
}, nil
}The FFmpeg args vary by extension:
func (v *VideoCompressor) ffmpegArgs(input, output, ext string) []string {
switch ext {
case ".webm":
return []string{"-y", "-i", input, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus", output}
case ".flv":
return []string{"-y", "-i", input, "-c:v", "flv1", "-c:a", "mp3", output}
default:
return []string{"-y", "-i", input, "-vcodec", "libx264", "-crf", "28", "-c:a", "aac", output}
}
}Audio Compression
backend/internal/adapter/compress/audio/audio.go does the same but for audio formats: .mp3, .wav, .flac, .aac, .ogg, .m4a.
Quality is converted to bitrate or sample rate:
func (a *AudioCompressor) ffmpegArgs(input, output, ext string, req compress.CompressionRequest) []string {
switch ext {
case ".mp3":
bitrate := qualityToBitrate(req.Quality)
return []string{"-y", "-i", input, "-c:a", "libmp3lame", "-b:a", bitrate, output}
case ".wav":
sampleRate := qualityToSampleRate(req.Quality)
return []string{"-y", "-i", input, "-ar", sampleRate, "-sample_fmt", "s16", output}
case ".flac":
return []string{"-y", "-i", input, "-c:a", "flac", "-compression_level", "8", output}
case ".ogg":
vorbisQ := qualityToVorbis(req.Quality)
return []string{"-y", "-i", input, "-c:a", "libvorbis", "-q:a", vorbisQ, output}
case ".m4a", ".aac":
bitrate := qualityToBitrate(req.Quality)
return []string{"-y", "-i", input, "-c:a", "aac", "-b:a", bitrate, output}
default:
bitrate := qualityToBitrate(req.Quality)
return []string{"-y", "-i", input, "-b:a", bitrate, output}
}
}Image Compression
backend/internal/adapter/compress/image/image.go uses the native image package.
func (i *ImageCompressor) Compress(req compress.CompressionRequest) (*compress.CompressionResult, error) {
ext := strings.ToLower(filepath.Ext(req.FileName))
data, err := io.ReadAll(req.Input)
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
originalSize := int64(len(data))
img, _, err := goimage.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
var buf bytes.Buffer
switch ext {
case ".jpg", ".jpeg":
quality := req.Quality
if quality <= 0 || quality > 100 {
quality = 60
}
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
case ".png":
encoder := &png.Encoder{CompressionLevel: png.BestCompression}
err = encoder.Encode(&buf, img)
default:
return nil, fmt.Errorf("unsupported image format: %s", ext)
}
if err != nil {
return nil, fmt.Errorf("failed to encode image: %w", err)
}
return &compress.CompressionResult{
Output: bytes.NewReader(buf.Bytes()),
OriginalSize: originalSize,
CompressedSize: int64(buf.Len()),
Format: ext,
}, nil
}JPEG gets quality control; PNG gets maximum compression.
Storage: S3 with Presigned URLs
The storage adapter (backend/internal/adapter/storage/storage.go) wraps the AWS SDK:
type Stg struct {
Client *s3.Client
Env env.EnvironmentVariables
}
func NewStorageClient(deps StgDeps) storage.Storage {
return &Stg{
Client: deps.Client,
Env: deps.Env,
}
}
func (s *Stg) Upload(ctx context.Context, filename string, file io.Reader) (string, error) {
key := "compressed/" + filename
_, err := s.Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.Env.S3.AWS_BUCKET),
Key: aws.String(key),
Body: file,
})
if err != nil {
return "", err
}
return key, nil
}
func (s *Stg) GenerateDownloadURL(ctx context.Context, filename string, expiry time.Duration) (string, error) {
presignClient := s3.NewPresignClient(s.Client)
req, err := presignClient.PresignGetObject(ctx,
&s3.GetObjectInput{
Bucket: aws.String(s.Env.S3.AWS_BUCKET),
Key: aws.String(filename),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL: %w", err)
}
return req.URL, nil
}Two operations:
- Upload: Write to S3 under
compressed/{filename}, return the key. - GenerateDownloadURL: Create a presigned URL valid for 24 hours.
This is the AWS SDK V2 approach (yeah, i had my share of headaches hopping from articles to docs).
Configuration
Environment variables are loaded in backend/pkg/env/env.go:
type EnvironmentVariables struct {
Port string
ProductionEnvironment bool
ClientDomain string
ProjectName string
STORAGE_TYPE string
S3 *S3Config
}
type S3Config struct {
AWS_REGION string
AWS_BUCKET string
AWS_ACCESS_KEY_ID string
AWS_SECRET_ACCESS_KEY string
}Required at startup:
AWS_REGIONAWS_BUCKETAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
Optional:
PORT(defaults to:5000)PRODUCTION_ENVIRONMENTCLIENT_DOMAINPROJECT_NAME
Response Format
Success responses come from backend/pkg/response/response.go:
type SuccessResponse struct {
StatusCode int `json:"statusCode,omitempty"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Metadata interface{} `json:"metadata,omitempty"`
}Error responses include the status code, message, and error detail:
type ErrorResponse struct {
StatusCode int `json:"statusCode"`
Message string `json:"message"`
ErrorMessage any `json:"error"`
}A successful compression returns:
{
"message": "success",
"data": {
"original_size": 52428800,
"compressed_size": 15728640,
"download_link": "https://bucket.s3.region.amazonaws.com/compressed/..."
}
}Demo
Conclusion
Building a media compression backend in Go is pretty straightforward and easy enough.
(If you want to see the full codebase, check the repository. you can also play around with the ui.)
