---
title: Upload product images
description: Accept two multipart image uploads, store them in a bucket, and verify the returned object keys.
pagination: false
sidebar:
  icon: upload
---

Add `POST /uploads` to an existing application. You'll send two images, store them in the assets bucket, and check the returned object keys.

> **Before you begin**
>
> You'll need a running RELKIT application, Docker for local MinIO, and two PNG images.

Create the four files below. If your app already has an Assets service or an uploads route, merge these members into it. Run commands from your app's root.

1. ## 1. Configure storage [#configure-storage]

    This local S3-compatible selection uses MinIO and requires a running Docker daemon. Install its
    adapter and Docker materializer from your app root:

    {/* Generated by apps/docs/scripts/generate.ts. Do not edit. */}

    From this guide's application/package root, install only its selected requirements:

    ```sh
    bun add @relkit/docker@0.7.12 @relkit/s3@0.7.12
    ```

    Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in `dependencies`; build/type tooling belongs in `devDependencies`. Installing an adapter does not supply its optional SDK peers or enable the feature's configuration.

    In `relkit.config.ts`, import `s3` from `@relkit/s3`, `docker` from `@relkit/docker`, and
    `env as binding` from `@relkit/app/config`. Create a `bucket` object inside `defineApp()` if the
    app has none, then add this `assets` entry:

    ```ts title="relkit.config.ts — bucket configuration"
    assets: docker(
      s3({
        endpoint: binding.url("ASSETS_S3_ENDPOINT"),
        bucketName: binding.string("ASSETS_S3_BUCKET"),
        region: binding.string("ASSETS_S3_REGION"),
        credentials: {
          accessKeyId: binding.secret("ASSETS_S3_ACCESS_KEY_ID"),
          secretAccessKey: binding.secret("ASSETS_S3_SECRET_ACCESS_KEY"),
        },
        forcePathStyle: true,
      }),
    ),
    ```

    `assets` starts a local MinIO recipe in development. The complete commerce application also has
    other bucket profiles; this first upload needs only `assets`. Connected and cloud ownership
    choices are described in [Define a bucket](/docs/modules/storage/define).

2. ## 2. Define the bucket [#define-the-bucket]

    ```ts title="src/services/assets/buckets/assets.bucket.ts"
    /** Declares private JSON/image assets with a five-million-byte limit for validated upload handlers. */
    import { defineBucket } from "@relkit/app/buckets";

    const assets = defineBucket({
      id: "assets.objects",
      profile: "assets",
      visibility: "private",
      maxObjectBytes: 5_000_000,
      allowedContentTypes: ["application/json", "image/*"],
    });

    export default assets;
    ```

3. ## 3. Add the upload function [#add-the-upload-function]

    The function validates the upload, saves each image, and returns the stored keys:

    ```ts title="src/services/assets/functions/upload-assets.function.ts"
    /** Validates multipart image files, writes them through the declared assets bucket and returns their uploaded names. */
    import { defineFunction } from "@relkit/app/functions";
    import { z } from "@relkit/app/schema";
    import assets from "../buckets/assets.bucket.js";

    const uploadedFile = z.file({
      maxBytes: 1024 * 1024 * 10,
      mediaTypes: ["image/jpeg", "image/png"],
    });

    const uploadAssets = defineFunction({
      input: z.object({
        label: z.string().min(1),
        primary: uploadedFile,
        attachments: z.array(uploadedFile),
      }),

      output: z.object({
        label: z.string(),
        files: z.array(z.string()),
      }),

      dependencies: { buckets: { assets } },

      handler: async ({ label, primary, attachments }, context) => {
        const files = [primary, ...attachments];

        // This demo uses filenames as keys; uploading the same name replaces the file.
        await Promise.all(
          files.map(async (file) =>
            context.buckets.assets.put(file.name, new Uint8Array(await file.arrayBuffer()), {
              contentType: file.type,
            }),
          ),
        );

        return { label, files: files.map(({ name }) => name) };
      },
    });

    export default uploadAssets;
    ```

    For this local example, filenames are the keys. Reusing a filename replaces that object. Use server-generated keys and authorize requests before accepting real users' uploads.

4. ## 4. Expose the endpoint [#expose-the-endpoint]

    Add the function to your Assets service:

    ```ts title="src/services/assets/service.ts"
    /** Declares the assets service boundary without acquiring native clients. */
    import { defineService } from "@relkit/app/services";
    import uploadAssets from "./functions/upload-assets.function.js";

    export default defineService({ functions: { uploadAssets } });
    ```

    Connect it to `POST /uploads`:

    ```ts title="src/routes/uploads/route.ts"
    /** Admits bounded multipart upload requests to the assets service's validated image handler. */
    import { defineRoute } from "@relkit/app/routes";
    import assets from "../../services/assets/service.js";

    export const POST = defineRoute({
      target: assets.uploadAssets,
      accept: "multipart/form-data",
      maxBodyBytes: 10 * 1024 * 1024,
    });
    ```

5. ## 5. Upload two images [#upload-two-images]

    Check and start the app:

    ```sh
    bun run check
    ```

    ```sh
    bun run dev
    ```

    The local MinIO recipe supplies generated binding-only credentials and loopback ports. Keep the app running
    and open a second terminal in the app's root. `bun run dev --local=off` disables recipes and requires the
    named connected/infrastructure values instead.

    Place two small PNG images named `cover.png` and `detail.png` there. Keep their combined size below 1 MB for this request, then send:

    ```sh
    curl -i http://localhost:3000/uploads \
      -F label=book-photos \
      -F 'primary=@cover.png;type=image/png' \
      -F 'attachments=@detail.png;type=image/png'
    ```

    Use your app's configured port if it differs from `3000`. Expect HTTP 200:

    ```json
    { "label": "book-photos", "files": ["cover.png", "detail.png"] }
    ```

6. ## 6. Check the stored files [#check-the-stored-files]

    Optionally complete the separate [Inspector installation, bridge and tool setup](/docs/modules/operations/inspector).
    Open its UI, normally at `http://localhost:3210`. Select **Buckets**, open `assets.objects`, and look
    for `cover.png` and `detail.png`. Click **Preview** beside either key to view its image.

    Tests must provide explicit replacements for profile `assets`; no environment name installs a file fake.
    You can now [read the files or create download links](/docs/modules/storage/read-delete). Before deploying, review
    ownership in [Integrations](/docs/modules/integrations) and add authentication to the upload endpoint.

## Related tasks

[Read files and create download links](/docs/modules/storage/read-delete), or [add
sign-in](/guides/auth/email-password-sign-in) before accepting uploads from users.
