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:
| Source | Authority | Setup required |
|---|---|---|
url | The caller supplies it — a presigned S3/GCS/Azure GET, or any allowed HTTPS URL. | None. Works out of the box. |
connection | You 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.
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_IMPORT_ENABLED | on | Set 0 to disable the import endpoint entirely (it answers 403). |
CLOUDPDF_IMPORT_MAX_BYTES | 134217728 (128 MiB) | Hard per-document ceiling, enforced against the declared length and again byte-by-byte during transfer. |
CLOUDPDF_IMPORT_TIMEOUT_MS | 120000 | Wall-clock budget for one transfer (connect + stream). |
CLOUDPDF_IMPORT_MAX_CONCURRENT | 4 | Transfers running at once; excess requests queue fairly. |
CLOUDPDF_IMPORT_ALLOW_HTTP | off | Permit http:// URL sources. For dev and internal MinIO only. |
CLOUDPDF_IMPORT_ALLOW_PRIVATE_NETWORKS | off | Permit 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-1That 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-archive → CUSTOMER_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:9000Credentials 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-projectAuthenticates 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-inboxReads 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:
| Variable | Default | Description |
|---|---|---|
..._CREDENTIALS | api-token | Which credential classes may use it: api-token, tenant-jwt, or both (comma-separated). |
..._TENANTS | * | Tenant allowlist (comma-separated), or * for all tenants. |
..._SCOPE | whole-bucket | What slice of the bucket is reachable: whole-bucket, shared-prefixes, or tenant-template. |
..._SCOPE_PREFIXES | — | For shared-prefixes: comma-separated key prefixes every allowed caller may read. |
..._SCOPE_TEMPLATE | — | For 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-prefixesgrants a fixed slice to every allowed caller — right for a common assets folder that all tenants may read.tenant-templategives 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, …).