CloudPDF
DocsPricing
Start building

Imports

Normally a client pushes bytes to CloudPDF (init → upload → commit). Imports invert that: the server pulls a PDF from where it already lives — a presigned URL, or a bucket you register — then verifies and commits it through the exact same pipeline. Nothing about the document is different afterwards; only who moved the bytes changed.

Two source models exist, and they differ by where authority comes from:

SourceAuthoritySetup required
urlThe caller supplies it — a presigned S3/GCS/Azure GET, or any allowed HTTPS URL.None. Works out of the box.
connectionYou pre-register it — a named bucket/directory with its own access rules.This page.

Import policy#

Imports are enabled by default with a safe posture: HTTPS-only, public networks only, bounded size and time.

VariableDefaultDescription
CLOUDPDF_IMPORT_ENABLEDonSet 0 to disable the import endpoint entirely (it answers 403).
CLOUDPDF_IMPORT_MAX_BYTES134217728 (128 MiB)Hard per-document ceiling, enforced against the declared length and again byte-by-byte during transfer.
CLOUDPDF_IMPORT_TIMEOUT_MS120000Wall-clock budget for one transfer (connect + stream).
CLOUDPDF_IMPORT_MAX_CONCURRENT4Transfers running at once; excess requests queue fairly.
CLOUDPDF_IMPORT_ALLOW_HTTPoffPermit http:// URL sources. For dev and internal MinIO only.
CLOUDPDF_IMPORT_ALLOW_PRIVATE_NETWORKSoffPermit URL sources that resolve to private/loopback addresses. For dev and in-VPC stores only.

Synchronous imports hold the HTTP response open for the whole transfer, so keep MAX_BYTES × your slowest source comfortably inside TIMEOUT_MS — the defaults give a same-region 128 MiB pull a wide margin. Larger or slower transfers belong in async mode (below), which is not bound by the request.

URL sources ship with server-side request forgery protection you don’t have to configure: only HTTPS, no redirects, no credentials in URLs, every resolved address must be publicly routable (cloud metadata endpoints and private ranges are refused), addresses are pinned against DNS rebinding, and presigned query strings never appear in logs, errors, or stored failure reasons. The two ALLOW_* switches deliberately loosen parts of this — leave them off in production.

Connections#

A connection is a named source you register once; import requests then reference it by id plus an object key — no presigning, no credentials on the wire, ever. Declare the list, then configure each name:

CLOUDPDF_IMPORT_CONNECTIONS=customer-archive
 
CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_KIND=s3
CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_S3_BUCKET=customer-documents
CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_S3_REGION=eu-west-1

That is a complete, working setup: the whole bucket becomes importable by key, restricted to the operator API token (the default — see Authorization below).

Names use [A-Za-z0-9_-] and are upper-snake-cased in the variable names (customer-archiveCUSTOMER_ARCHIVE). Two names that collide after normalization refuse to boot.

S3 and S3-compatible#

CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_KIND=s3
CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_S3_BUCKET=customer-documents
CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_S3_REGION=eu-west-1
# MinIO / R2 / Wasabi:
CLOUDPDF_IMPORT_CONNECTION_CUSTOMER_ARCHIVE_S3_ENDPOINT=https://minio.internal:9000

Credentials follow the standard AWS provider chain — an instance role, IRSA, or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY — exactly like storage. Nothing credential-shaped goes in the connection itself. Grant the role s3:GetObject on the source bucket (plus s3:GetObjectVersion if you pin versions).

Google Cloud Storage#

CLOUDPDF_IMPORT_CONNECTION_LEGAL_SCANS_KIND=gcs
CLOUDPDF_IMPORT_CONNECTION_LEGAL_SCANS_GCS_BUCKET=legal-scans
# Optional: CLOUDPDF_IMPORT_CONNECTION_LEGAL_SCANS_GCS_PROJECT_ID=my-project

Authenticates via Application Default Credentials (workload identity, or a key file referenced by GOOGLE_APPLICATION_CREDENTIALS).

Azure Blob Storage#

CLOUDPDF_IMPORT_CONNECTION_HR_DOCS_KIND=azure-blob
CLOUDPDF_IMPORT_CONNECTION_HR_DOCS_AZURE_BLOB_CONTAINER=hr-documents
CLOUDPDF_IMPORT_CONNECTION_HR_DOCS_AZURE_BLOB_ACCOUNT_NAME=contosoprod
# Optional custom endpoint:
# CLOUDPDF_IMPORT_CONNECTION_HR_DOCS_AZURE_BLOB_ENDPOINT=https://...

Authenticates via the standard Azure identity chain (managed identity, workload identity, or az login locally). Grant the identity blob read access on the container.

Filesystem#

CLOUDPDF_IMPORT_CONNECTION_LOCAL_DROP_KIND=fs
CLOUDPDF_IMPORT_CONNECTION_LOCAL_DROP_FS_ROOT=/srv/import-inbox

Reads files from a directory on the server host — useful for migration workflows (“drop the files on the volume, import by path”). Keys are /-separated paths relative to the root; absolute paths, .. segments, and symlinks that escape the root are all refused.

Filesystem connections are operator-only by construction: they always require the API token, and a configuration that tries to open one to tenant tokens refuses to boot. A directory on the server’s own disk is not a tenant-facing surface.

Authorization: who may use a connection#

Registering a connection is the moment you grant access — so each connection carries its own rules, all enforced before a single byte moves:

VariableDefaultDescription
..._CREDENTIALSapi-tokenWhich credential classes may use it: api-token, tenant-jwt, or both (comma-separated).
..._TENANTS*Tenant allowlist (comma-separated), or * for all tenants.
..._SCOPEwhole-bucketWhat slice of the bucket is reachable: whole-bucket, shared-prefixes, or tenant-template.
..._SCOPE_PREFIXESFor shared-prefixes: comma-separated key prefixes every allowed caller may read.
..._SCOPE_TEMPLATEFor tenant-template: a per-tenant prefix like tenants/{tenantId}/.

The default is deliberately strict: whole-bucket access belongs to the operator’s API token only. Importing is effectively reading — whoever can import an object can then view it — so opening a connection to tenant tokens is only allowed when the connection also says which slice each tenant gets:

  • shared-prefixes grants a fixed slice to every allowed caller — right for a common assets folder that all tenants may read.
  • tenant-template gives each tenant its own slice, computed from the authenticated tenant id at request time. One line scales to any number of tenants with zero per-tenant configuration:
CLOUDPDF_IMPORT_CONNECTION_ACME_DOCS_KIND=s3
CLOUDPDF_IMPORT_CONNECTION_ACME_DOCS_S3_BUCKET=app-documents
CLOUDPDF_IMPORT_CONNECTION_ACME_DOCS_S3_REGION=eu-west-1
CLOUDPDF_IMPORT_CONNECTION_ACME_DOCS_CREDENTIALS=api-token,tenant-jwt
CLOUDPDF_IMPORT_CONNECTION_ACME_DOCS_SCOPE=tenant-template
CLOUDPDF_IMPORT_CONNECTION_ACME_DOCS_SCOPE_TEMPLATE=tenants/{tenantId}/

With this, tenant acme can import keys under tenants/acme/ and nothing else — including no tenants/acme-other/, because templates must end with / precisely so tenant slices can never prefix-collide.

All of this is validated at boot, fail-closed. A connection that opens tenant-jwt on a whole bucket, a template without exactly one {tenantId} placeholder or without the trailing slash, mismatched scope variables, or a connection pointing at the server’s own storage backend (which would let imports read CloudPDF’s own bucket) — each refuses to start with a message naming the connection and the rule.

Async imports and the worker#

Clients may submit an import with mode: "async": the API answers 202 immediately and a background worker performs the transfer with automatic retries. There is nothing to deploy or configure for this — the worker runs inside the server process (like the cleanup sweeper), starts whenever imports are enabled, and is safe with multiple replicas: jobs are claimed with expiring leases, and a replica that dies mid-transfer has its job picked up by another.

Two knobs you already know shape it: CLOUDPDF_IMPORT_MAX_CONCURRENT bounds transfers per replica (sync and async share the budget), and CLOUDPDF_IMPORT_TIMEOUT_MS bounds each attempt. Failed attempts retry with exponential backoff (30s doubling, capped at 15 minutes, five attempts) before the document is marked failed.

Every import — sync or async — writes a provenance row in the document_imports table: which connection or URL, which object version was actually served, which credential and actor requested it, and the outcome. It’s your audit trail for “who pulled what, from where”: query it directly, or check a document’s failureReason for the sanitized error class (import_not_found, import_denied, import_policy, …).

Next steps#