|
| 1 | +# ImgProxy cache |
| 2 | + |
| 3 | +A transparent caching reverse proxy for [imgproxy](https://imgproxy.net/) that automatically uploads processed images to S3-compatible storage (Tigris, AWS S3, MinIO, etc.). |
| 4 | + |
| 5 | +## What It Does |
| 6 | + |
| 7 | +This application acts as a transparent layer in front of imgproxy: |
| 8 | + |
| 9 | +1. **Receives** image processing requests |
| 10 | +2. **Proxies** them to imgproxy for processing |
| 11 | +3. **Returns** the processed image to the client immediately |
| 12 | +4. **Uploads** the processed image to Tigris or S3 asynchronously for future use |
| 13 | + |
| 14 | +The upload happens in the background, so client responses are not delayed. This creates a "cache-on-write" pattern where every successfully processed image is automatically stored in the target bucket. |
| 15 | + |
| 16 | +## Architecture |
| 17 | + |
| 18 | +``` |
| 19 | +Client Request |
| 20 | + ↓ |
| 21 | +[imgproxy-cache :8080] ← This application |
| 22 | + ↓ |
| 23 | +Response → Client (immediate) |
| 24 | + ↓ |
| 25 | +S3 Upload (background) |
| 26 | +``` |
| 27 | + |
| 28 | +The proxy: |
| 29 | +- Buffers the complete response in memory |
| 30 | +- Sends it immediately to the client |
| 31 | +- Spawns a goroutine to upload to S3 |
| 32 | +- Logs upload success/failure without blocking |
| 33 | + |
| 34 | +## Use Cases |
| 35 | + |
| 36 | +- **Persistent Cache**: Ensure processed images are stored durably, even if imgproxy's local cache is cleared |
| 37 | +- **Multi-Region**: Process images once, store in Tigris or S3, serve from multiple regions |
| 38 | +- **Cost Optimization**: Reduce repeated processing of the same images |
| 39 | + |
| 40 | +## Installation |
| 41 | + |
| 42 | +### Using Docker (Recommended) |
| 43 | + |
| 44 | +The Docker image includes both imgproxy (v3.30) and the Go proxy in a single container: |
| 45 | + |
| 46 | +```bash |
| 47 | +docker build -t imgproxy-cache . |
| 48 | +docker run -p 8080:8080 \ |
| 49 | + -e S3_BUCKET="your-bucket" \ |
| 50 | + -e AWS_ACCESS_KEY_ID="your-key" \ |
| 51 | + -e AWS_SECRET_ACCESS_KEY="your-secret" \ |
| 52 | + imgproxy-cache |
| 53 | +``` |
| 54 | + |
| 55 | +When the container starts: |
| 56 | +1. imgproxy starts on `127.0.0.1:8081` (internal) |
| 57 | +2. The Go proxy starts on `:8080` (exposed) |
| 58 | +3. Both processes run under supervision - if either exits, the container stops |
| 59 | + |
| 60 | +You can pass imgproxy-specific configuration via environment variables prefixed with `IMGPROXY_`: |
| 61 | + |
| 62 | +```bash |
| 63 | +docker run -p 8080:8080 \ |
| 64 | + -e S3_BUCKET="your-bucket" \ |
| 65 | + -e AWS_ACCESS_KEY_ID="your-key" \ |
| 66 | + -e AWS_SECRET_ACCESS_KEY="your-secret" \ |
| 67 | + -e IMGPROXY_MAX_SRC_RESOLUTION=16384 \ |
| 68 | + -e IMGPROXY_QUALITY=90 \ |
| 69 | + imgproxy-cache |
| 70 | +``` |
| 71 | + |
| 72 | +See [imgproxy documentation](https://docs.imgproxy.net/configuration) for all available options. |
| 73 | + |
| 74 | +## Example client code |
| 75 | +### HTML |
| 76 | +```html |
| 77 | +<img |
| 78 | + src="https://process-image-url" |
| 79 | + onerror="this.onerror=null; this.src='https://proxy-url/image-processing-params/original-image-url';" |
| 80 | +/> |
| 81 | +``` |
| 82 | + |
| 83 | +### Elixir (with imgproxy signing) |
| 84 | + |
| 85 | +```elixir |
| 86 | + @doc """ |
| 87 | + Renders an image with proxy URL transformation. |
| 88 | + The image URL will be transformed to: <image_proxy_url>/<dimensions>/<image_url> |
| 89 | + """ |
| 90 | + attr :src, :string, required: true |
| 91 | + attr :dimensions, :string, required: true |
| 92 | + attr :resize_mode, :string, default: "fit", values: ["fit", "fill"] |
| 93 | + attr :class, :string, default: nil |
| 94 | + attr :alt, :string, default: nil |
| 95 | + |
| 96 | + def image(assigns) do |
| 97 | + img_path = |
| 98 | + "/rs:#{assigns.resize_mode}:#{assigns.dimensions}:1/dpr:2/g:ce/" <> |
| 99 | + Base.encode64(assigns.src) <> ".webp" |
| 100 | + |
| 101 | + signature = |
| 102 | + :crypto.mac( |
| 103 | + :hmac, |
| 104 | + :sha256, |
| 105 | + Base.decode16!( |
| 106 | + System.get_env("IMGPROXY_KEY"), |
| 107 | + case: :lower |
| 108 | + ), |
| 109 | + Base.decode16!( |
| 110 | + System.get_env("IMGPROXY_SALT"), |
| 111 | + case: :lower |
| 112 | + ) <> img_path |
| 113 | + ) |
| 114 | + |> Base.url_encode64(padding: false) |
| 115 | + |
| 116 | + full_path = "/" <> signature <> img_path |
| 117 | + |
| 118 | + assigns = |
| 119 | + assigns |
| 120 | + |> assign(:proxy_src, "#{Application.get_env(:manage, :image_proxy_url)}#{full_path}") |
| 121 | + |> assign( |
| 122 | + :cached_src, |
| 123 | + Application.get_env(:manage, :image_cache_url) <> |
| 124 | + "/" <> |
| 125 | + (:crypto.hash(:md5, full_path) |
| 126 | + |> Base.encode16(case: :lower)) |
| 127 | + ) |
| 128 | + |
| 129 | + ~H""" |
| 130 | + <img |
| 131 | + src={@cached_src} |
| 132 | + class={@class} |
| 133 | + alt={@alt} |
| 134 | + onerror={"this.onerror=null; this.src='#{@proxy_src}';"} |
| 135 | + /> |
| 136 | + """ |
| 137 | + end |
| 138 | +``` |
| 139 | + |
| 140 | +## Configuration |
| 141 | + |
| 142 | +### Environment Variables |
| 143 | + |
| 144 | +| Variable | Required | Default | Description | |
| 145 | +|----------|----------|---------|-------------| |
| 146 | +| `S3_BUCKET` | **Yes** | - | S3 bucket name where images will be stored | |
| 147 | +| `S3_FOLDER` | No | `""` | Prefix/folder path within the bucket | |
| 148 | +| `S3_ENDPOINT` | No | `https://fly.storage.tigris.dev` | S3-compatible endpoint URL | |
| 149 | +| `IMGPROXY_BIND` | No | `:8080` | Address and port for the proxy to bind to | |
| 150 | +| `HEALTH_CHECK_TIMEOUT_IN_SEC` | No | `30` | Seconds to wait for imgproxy to become healthy | |
| 151 | + |
| 152 | +### AWS Credentials |
| 153 | + |
| 154 | +The application uses the AWS SDK v2, which automatically loads credentials from: |
| 155 | +- Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) |
| 156 | +- Shared credentials file (`~/.aws/credentials`) |
| 157 | +- IAM roles (when running on EC2/ECS/Lambda) |
| 158 | +- Web identity tokens (when running on EKS) |
| 159 | + |
| 160 | +See [AWS SDK documentation](https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/) for full details. |
| 161 | + |
| 162 | +## How Caching Works |
| 163 | + |
| 164 | +### Key Generation |
| 165 | + |
| 166 | +S3 keys are generated by MD5 hashing the imgproxy URL path: |
| 167 | + |
| 168 | +``` |
| 169 | +Request: /resize:fill:300:300/plain/https://example.com/image.jpg |
| 170 | +S3 Key: a3f8c9d2e1b4f7a6c8d9e2f1b3a4c5d6 |
| 171 | +``` |
| 172 | + |
| 173 | +This ensures: |
| 174 | +- **Consistent**: Same URL always maps to the same S3 key |
| 175 | +- **Compact**: Keys are fixed-length 32 characters |
| 176 | +- **Safe**: No special characters or path traversal issues |
| 177 | + |
| 178 | +### Upload Behavior |
| 179 | + |
| 180 | +- **Only successful responses** (HTTP 200) are uploaded |
| 181 | +- **Uploads are asynchronous** - client doesn't wait for S3 confirmation |
| 182 | +- **Failed uploads are logged** but don't affect the client response |
| 183 | +- **No deduplication** - same request will re-upload (consider implementing checks) |
| 184 | + |
| 185 | +### Storage Structure |
| 186 | + |
| 187 | +``` |
| 188 | +s3://your-bucket/ |
| 189 | + └── processed/ (if S3_FOLDER is set) |
| 190 | + ├── a3f8c9d2e1b4f7a6... (image 1) |
| 191 | + ├── b2e7d8c1f0a9e3b7... (image 2) |
| 192 | + └── c9f1a2b3e4d5c6a7... (image 3) |
| 193 | +``` |
| 194 | + |
| 195 | +## Usage Example |
| 196 | + |
| 197 | +### Start the Service |
| 198 | + |
| 199 | +```bash |
| 200 | +export S3_BUCKET="my-images" |
| 201 | +export AWS_ACCESS_KEY_ID="your-key" |
| 202 | +export AWS_SECRET_ACCESS_KEY="your-secret" |
| 203 | + |
| 204 | +./imgproxy-cache |
| 205 | +``` |
| 206 | + |
| 207 | +### Process an Image |
| 208 | + |
| 209 | +```bash |
| 210 | +# Request an image through the proxy |
| 211 | +curl http://localhost:8080/resize:fill:300:300/plain/https://example.com/cat.jpg > output.jpg |
| 212 | + |
| 213 | +# The processed image is: |
| 214 | +# 1. Returned immediately to your curl command |
| 215 | +# 2. Uploaded to S3 in the background |
| 216 | +``` |
| 217 | + |
| 218 | +### Check Logs |
| 219 | + |
| 220 | +``` |
| 221 | +2025/10/20 10:30:00 INFO Waiting for imgproxy to be ready... |
| 222 | +2025/10/20 10:30:01 INFO imgproxy is ready |
| 223 | +2025/10/20 10:30:15 INFO Uploaded to S3 path=/resize:fill:300:300/plain/https://example.com/cat.jpg bucket=my-images key=a3f8c9d2e1b4f7a6c8d9e2f1b3a4c5d6 |
| 224 | +``` |
| 225 | + |
| 226 | + |
| 227 | +## Development |
| 228 | + |
| 229 | +### Testing |
| 230 | + |
| 231 | +```bash |
| 232 | +go test -v ./... |
| 233 | +``` |
| 234 | + |
| 235 | +## Limitations & Considerations |
| 236 | + |
| 237 | +- **Memory Usage**: Entire response is buffered in memory before upload |
| 238 | +- **No Retry Logic**: Failed S3 uploads are not retried |
| 239 | +- **No Deduplication**: Same image can be uploaded multiple times |
| 240 | +- **No Cleanup**: Old/unused images are never deleted from S3 |
| 241 | +- **No Validation**: Uploads happen even if the same key already exists in S3 |
| 242 | + |
| 243 | +## License |
| 244 | + |
| 245 | +[MIT LICENSE](./LICENSE.md) |
0 commit comments