Serving images from S3

Copy Markdown View Source

Serve originals from Amazon S3 or S3-compatible storage, such as MinIO or Cloudflare R2, in private buckets. This guide assumes ImagePipe is running in your app (see Getting started with Phoenix) or as image_pipe_server (see getting started with the server).

Add an S3 source

Give the source the bucket's region and endpoint, and credentials allowed to read objects (s3:GetObject):

Plug

sources: [
  media: [
    adapter: ImagePipe.Source.S3,
    match: [scheme: "s3"],
    options: [
      default: [
        region: "eu-west-1",
        endpoint: "https://s3.eu-west-1.amazonaws.com",
        credentials: {:provider, ImagePipe.Source.S3.InstanceRole, []}
      ]
    ]
  ]
]

image_pipe_server

[sources.media]
adapter = "s3"
match = { scheme = "s3" }
region = "eu-west-1"
endpoint = "https://s3.eu-west-1.amazonaws.com"
credentials = { provider = "instance_role" }

For other S3-compatible stores, set endpoint to their API URL. Objects are requested path-style, as <endpoint>/<bucket>/<key>. Image URLs name an object after src/ as s3://bucket/key:

/w=400/src/s3://my-bucket/photos/beach.jpg

Choose credentials

Pick the credentials that match where ImagePipe runs:

ImagePipe refreshes temporary credentials before they expire, and never sends expired ones. If they can't be refreshed, requests fail with 500 until they can.

Use static keys

Plug

credentials:
  {:static,
   access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
   secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")}

For temporary keys, add token: System.get_env("AWS_SESSION_TOKEN"). When AWS_SESSION_TOKEN is unset, the keys are used without a session token.

image_pipe_server

Remove credentials from the file, and set the keys with environment variables, reading the secret from a file:

IPS_SOURCES__MEDIA__CREDENTIALS__STATIC__ACCESS_KEY_ID=AKIA…
IPS_SOURCES__MEDIA__CREDENTIALS__STATIC__SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret

For temporary keys, also set IPS_SOURCES__MEDIA__CREDENTIALS__STATIC__TOKEN.

Use an EC2 instance role

Attach a role that can read the bucket to the instance. ImagePipe reads its credentials from the instance metadata service (IMDSv2):

Plug

credentials: {:provider, ImagePipe.Source.S3.InstanceRole, []}

image_pipe_server

credentials = { provider = "instance_role" }

Use container credentials

Give the ECS task a task role that can read the bucket. ECS sets AWS_CONTAINER_CREDENTIALS_RELATIVE_URI in the container, which locates the task role's credentials:

Plug

credentials:
  {:provider, ImagePipe.Source.S3.ContainerCredentials,
   relative_uri: System.fetch_env!("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")}

If the platform sets AWS_CONTAINER_CREDENTIALS_FULL_URI instead, pass it as full_uri:, with the token from AWS_CONTAINER_AUTHORIZATION_TOKEN as auth_token:. EKS Pod Identity sets AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE instead of AWS_CONTAINER_AUTHORIZATION_TOKEN. Pass that path as auth_token_file:. The file is read again on every refresh, since EKS rotates the token.

image_pipe_server

credentials = { provider = "container_credentials" }

The server fills relative_uri, full_uri, auth_token, and auth_token_file from AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, AWS_CONTAINER_CREDENTIALS_FULL_URI, AWS_CONTAINER_AUTHORIZATION_TOKEN, and AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE, so the same setting works on ECS and with EKS Pod Identity. Setting any of the four in credentials stops it filling the others.

Use an EKS service account role

With IAM roles for service accounts (IRSA), EKS mounts a token file into the pod. ImagePipe exchanges it for credentials of the service account's role:

Plug

credentials:
  {:provider, ImagePipe.Source.S3.WebIdentity,
   token_file: System.fetch_env!("AWS_WEB_IDENTITY_TOKEN_FILE"),
   role_arn: System.fetch_env!("AWS_ROLE_ARN"),
   region: System.fetch_env!("AWS_REGION")}

image_pipe_server

credentials = { provider = "web_identity" }

The server reads token_file, role_arn, and region from AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN, and AWS_REGION, which EKS sets. Settings in credentials override these variables.

Assume a role in another account

To read a bucket in another account, assume a role there that can read it. The base credentials, any of the forms above, must be allowed to assume the role:

Plug

credentials:
  {:provider, ImagePipe.Source.S3.AssumeRole,
   base: {:provider, ImagePipe.Source.S3.InstanceRole, []},
   role_arn: "arn:aws:iam::123456789012:role/image-read",
   external_id: "image-pipe",
   region: "eu-west-1"}

image_pipe_server

[sources.media.credentials]
provider = "assume_role"
base = { provider = "instance_role" }
role_arn = "arn:aws:iam::123456789012:role/image-read"
external_id = "image-pipe"
region = "eu-west-1"

external_id is needed only when the role's trust policy requires one.

Serve only some buckets

By default the source serves any bucket the credentials can read. List buckets to serve only those. Each one can override the source's settings, such as its region or credentials:

Plug

options: [
  default: [
    region: "eu-west-1",
    endpoint: "https://s3.eu-west-1.amazonaws.com",
    credentials: {:provider, ImagePipe.Source.S3.InstanceRole, []}
  ],
  buckets: %{
    "photos" => [],
    "archive" => [
      region: "us-east-1",
      endpoint: "https://s3.us-east-1.amazonaws.com"
    ]
  }
]

image_pipe_server

[sources.media.buckets.photos]

[sources.media.buckets.archive]
region = "us-east-1"
endpoint = "https://s3.us-east-1.amazonaws.com"

Other buckets then answer 404.

Fetch credentials at startup

Credentials from a provider are fetched on the first request for each bucket, which adds a round trip to that request. To fetch them at startup instead:

Plug

Add an ImagePipe.Source.S3.CredentialWarmup for each bucket to your supervision tree, with the same provider and options as the source:

# lib/my_app/application.ex
children = [
  {ImagePipe.Source.S3.CredentialWarmup,
   provider: ImagePipe.Source.S3.InstanceRole, opts: [], scope: "photos"},
  {ImagePipe.Source.S3.CredentialWarmup,
   provider: ImagePipe.Source.S3.InstanceRole, opts: [], scope: "archive"},
  MyAppWeb.Endpoint
]

image_pipe_server

The server fetches credentials at startup for each bucket listed under buckets whose credentials come from a provider.

The fetched credentials are kept, and refreshed before they expire, until the first request for the bucket. After that, credentials that no request uses are dropped after five to ten minutes. A failed fetch at startup is retried by the first request.

Request object versions

In a bucket with versioning on, add a version ID as the whole query to request that version, written ? as %3F:

/w=400/src/s3://my-bucket/photos/beach.jpg%3F3HL4kqtJlcpX

A versioned object never changes, so cached images made from it are served without checking S3 (see immutable sources). If the store ignores version IDs, the request fails with 502 rather than serving the current object.

Confirm it works

Request an object through the source. If your URLs are signed, sign this one too.

Plug

$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:4000/images/w=400/src/s3://my-bucket/photos/beach.jpg
200 image/jpeg

image_pipe_server

The server listens on port 8080 and serves from / unless [server] sets another port or mount_path.

$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:8080/w=400/src/s3://my-bucket/photos/beach.jpg
200 image/jpeg

A missing object, or one the credentials can't read, answers 404. A 500 can mean the credentials couldn't be fetched.

Next steps