> ## Documentation Index
> Fetch the complete documentation index at: https://docs.markpdf.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Own storage (self-hosted S3)

> Open source alternatives to S3/R2 for the BYOS flow (input_url / output_url), without depending on a cloud provider.

# Own storage (self-hosted S3)

The BYOS stream (`url` input + `output_url` output) only needs storage that speaks **the S3 protocol** (PUT/GET with pre-signed URLs). It does not have to be AWS S3 or Cloudflare R2: any S3-compatible server that you can run yourself works the same, with the same AWS/S3 SDK you already know for generating signed URLs.

<Note>
  markpdf's API does not know or care what storage implementation is behind `url` / `output_url`. You only need one signed URL to make `GET` (input) or `PUT` (output). Everything on this page happens on **your** infrastructure, not inside the API.
</Note>

## When is convenient for you

* You want to keep your users' documents in your own VPS/datacenter for compliance or cost.
* You already have a server with plenty of disk/network and you don't want to pay egress from a cloud provider.
* You are in local development and do not want to depend on credentials from a real bucket.

If you already use S3, R2, GCS or Supabase Storage and it works well for you, there is no need to migrate — this page is for those who want to avoid that dependency.

## S3-compatible options that you can self-host

| Project                                                         | When to choose it                                                                                                                                              |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **[MinIO](https://min.io/)**                                    | The most popular option. S3 API almost 1:1, single binary, web console included. Best starting point if you have never set up your own storage.                |
| **[Garage](https://garagehq.deuxfleurs.fr/)**                   | Written in Rust, intended for small or geo-distributed clusters with modest hardware. Good option if you want replication between several cheap VPS.           |
| **[SeaweedFS](https://github.com/seaweedfs/seaweedfs)**         | Optimized for many small/medium files with minimal overhead per object. Includes S3 gateway.                                                                   |
| **[Ceph (RGW)](https://docs.ceph.com/en/latest/radosgw/)**      | For when you already have, or plan to have, a large Ceph cluster; RADOS Gateway exposes the S3 API on top of it. Heavier to operate than the previous options. |
| **[Zenko CloudServer](https://github.com/scality/cloudserver)** | S3 API in Node.js, useful if your stack is already all JS and you want an embeddable/test storage backend with disk or memory storage.                         |

They all expose the same protocol, so the code that generates signed URLs (`getSignedUrl` / `generate_presigned_url`) is interchangeable between them and S3/R2. You only change `endpoint`, credentials, and `forcePathStyle`/`s3ForcePathStyle`.

## Generate signed URLs against your own server

Use the normal AWS SDK pointing to the `endpoint` of your self-hosted server. It works the same with MinIO, Garage, SeaweedFS, or Ceph RGW.

<CodeGroup>
  ```ts Node.js (aws-sdk v3) theme={null}
  import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

  const s3 = new S3Client({
    endpoint: "https://storage.your-server.com", // MinIO/Garage/SeaweedFS/Ceph RGW
    region: "us-east-1", // valor arbitrario, la mayoría lo ignora
    forcePathStyle: true, // requerido por MinIO/Garage/SeaweedFS
    credentials: { accessKeyId: "...", secretAccessKey: "..." },
  });

  const inputUrl = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: "documents", Key: "report.pdf" }),
    { expiresIn: 900 },
  );

  const outputUrl = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: "documents", Key: "report.md" }),
    { expiresIn: 900 },
  );
  ```

  ```python Python (boto3) theme={null}
  import boto3

  s3 = boto3.client(
      "s3",
      endpoint_url="https://storage.your-server.com",
      aws_access_key_id="...",
      aws_secret_access_key="...",
  )

  input_url = s3.generate_presigned_url(
      "get_object", Params={"Bucket": "documents", "Key": "report.pdf"}, ExpiresIn=900
  )

  output_url = s3.generate_presigned_url(
      "put_object", Params={"Bucket": "documents", "Key": "report.md"}, ExpiresIn=900
  )
  ```
</CodeGroup>

With the generated URLs, the flow is the same as always:

```json theme={null}
{
  "url": "https://storage.your-server.com/documents/report.pdf?X-Amz-Signature=...",
  "output_url": "https://storage.your-server.com/documents/report.md?X-Amz-Signature=...",
  "output_encoding": "zstd"
}
```

See [`output_url`](/docs/api/parameters#output_url) and [Output compression](/docs/concepts/compression#output-compression-output_encoding).

## Practical notes

<Tip>
  `forcePathStyle` (or `s3ForcePathStyle` in older SDKs) is almost always necessary against MinIO/Garage/SeaweedFS: these servers do not support the `bucket.endpoint` style by default, only `endpoint/bucket`.
</Tip>

<Warning>
  Your storage server must be reachable by `https` from where the API runs (not `localhost` or a private IP, unless you have deployed the API on your own network). If the host is not publicly resolvable, the API will not be able to do `GET`/`PUT` against `url`/`output_url`, and conversion will fail with `400`/`502`.
</Warning>

* Put TLS in front of your storage server (reverse proxy with Let's Encrypt, for example) — signed URLs travel over the public network.
* Expires signed URLs at the minimum reasonable time (minutes, not days); generates a new one for each conversion.
* If you migrate from S3/R2 to your own storage later, only change `endpoint` and credentials; the rest of the code (URL signing, calling the API) does not change.
