Production setup
Read this if the Quickstart works and you are preparing a deployment for real traffic. You will move the signing key into AWS KMS or Vault, give every host one evidence sink, register for an issuer number, lock down the sidecar, open only the outbound connections Laidline needs, and wire up health checks. Skip if you are still trying Laidline out (Quickstart). Time: an afternoon, plus your certificate authority's lead time for a trust-list certificate. Needs:
pip install "laidline[image,audio,server,kms,evidence]", an AWS KMS key or a Vault/OpenBao Transit key, an S3 bucket with Object Lock or a Postgres database, and an email address at your organisation's domain.
Before you start
-
A
laidline.yamlfromlaidline init, tried through the Quickstart. You change it in place; keeppayload-k1.keybeside it and never commit either to a repository. -
The production extras on every host:
pip install "laidline[image,audio,server,kms,evidence]"
Add
video,textoringestfor what you mark (Install). -
The weights on every host or in your image (
laidline models fetch), so no request waits for a download.
The order matters in one place: registration binds your licence to the signing key, so move the key first (steps 1 and 2) and register after (step 4). The certificate can change later without a new registration as long as the key stays the same.
1. Move the signing key into AWS KMS or Vault
In production the private key that signs your Content Credentials lives in a key manager and never on disk. Create an ECC P-256 signing key in AWS KMS:
aws kms create-key --key-spec ECC_NIST_P256 --key-usage SIGN_VERIFY --description "Laidline signing key"
Expected output (shortened):
{
"KeyMetadata": {
"Arn": "arn:aws:kms:<region>:<account>:key/<id>",
"KeySpec": "ECC_NIST_P256",
"KeyUsage": "SIGN_VERIFY",
...
Give the role your hosts run as kms:Sign and kms:GetPublicKey on that key, nothing more. Then point
laidline.yaml at it:
signing: backend: aws-kms aws: key_id: arn:aws:kms:<region>:<account>:key/<id> region: <region> cert_chain_path: ./certs/dev-chain.pem # step 2 writes it; later your trust-list chain
What just happened: the key was created inside KMS and cannot be exported; Laidline sends each manifest's digest to
KMS and gets the signature back. With HashiCorp Vault or OpenBao, use a Transit key of type ecdsa-p256 and
signing.backend: vault-transit instead, with signing.vault.addr, key_name, an explicit key_version and the token
in the variable signing.vault.token_env names (Configuration,
Certificates).
2. Get a certificate for the key
Every manifest carries a certificate chain for the signing key. Until your trust-list certificate arrives, certify the KMS key with a development CA, so the deployment can start:
laidline keys dev-cert --kms-key-id "arn:aws:kms:<region>:<account>:key/<id>" --region <region> --org "Example Org" --cn "Example image signer"
Expected output:
wrote ./certs/dev-chain.pem and dev-ca.pem for KMS key arn:aws:kms:<region>:<account>:key/<id>; set signing.backend: aws-kms and signing.aws.key_id: arn:aws:kms:<region>:<account>:key/<id>
signing.cert_chain_path already points at it. For a manifest that other validators trust, you need a
certificate from a CA on the C2PA trust list. Such a CA certifies only a generator product listed with the C2PA
Conformance Program, so list your AI system first, then send the CA a signing request made by the key itself:
laidline keys csr --kms-key-id "arn:aws:kms:<region>:<account>:key/<id>" --region <region> --org "Example Org" --cn "Example image signer" --out signer.csr
The command prints the key it used and the SHA-256 of its public key, and writes signer.csr; only the public key
leaves KMS.
What just happened: the development chain lets the deployment run now; your own detect trusts it, and other
validators report the manifest as valid but not trusted. When the CA returns the chain, save it leaf first, set
signing.cert_chain_path to it and restart. Laidline checks the chain at start (organisation in the subject,
digitalSignature, an accepted extended key usage, the public key matching the KMS key) and names the failing check
if one fails. The profile, the accepted key usages and key rotation without a restart are in
Certificates.
3. Give every host one evidence sink
The evidence log is your record of every mark, the meter for credits, and the registry detect reads to confirm a
watermark (grade B). A file sink on each host splits it: another host then reads your registered marks as grade D
with registry: missing. Make the first sink one that every host writes to. With S3 and Object Lock:
evidence: sinks: - type: s3 bucket: <your-evidence-bucket> prefix: laidline/ region: <region> object_lock: true
Or with Postgres, after creating the schema once as a separate migrator role:
laidline evidence init-db
evidence: sinks: - type: postgres dsn_env: LAIDLINE_PG_DSN
What just happened: sinks[0] is now authoritative: a failed write there fails the mark, and detect, the usage
statement and the evidence export read it. The S3 sink checks at start that the bucket has Object Lock and needs
s3:PutObject, s3:GetObject, s3:ListBucket and s3:GetBucketObjectLockConfiguration, never a delete
permission. The Postgres app role can only read and insert. Postgres is the faster primary at high volume; the
measured write latencies are in Benchmarks. Later sinks are replicas
(Evidence log).
4. Register the deployment
Registration gives you a registered issuer number (1 to 32,767, never reused), a free licence and an issuer certificate that binds your signing key and your private payload key. Your marks then name you. Run it on the host that holds the production configuration:
laidline register --email you@yourcompany.com --display-name "Yourcompany Inc" --plan registered_free --accept-terms
Expected output, after you follow the link in the email:
check you@example.com for the link (registration <registration id>) waiting for the link to be clicked licence <licence id>: plan registered_free, valid until <date> issuer <number>: display public as <display name> issuer: registered <number> as <display name> reload: send SIGHUP to laidline serve or restart it
What just happened: register signed a request with your signing key, carrying your acceptance of the
Laidline Customer Licence Agreement, waited for the email
link (30 minutes by default, --timeout), then verified both documents under the two Laidline root keys pinned in
the package (The Laidline roots) and installed the licence in licence.d/ and the certificate
as issuer-certificate.json beside laidline.yaml. Your configuration keeps its trial number: the certificate lists
it, and Laidline uses the registered number for private marks. The directory publishes your display name and domain
beside the number; a name you give with --display-name must contain your domain's label. Reload the sidecar, then check:
laidline licence status
The issuer: line now reads registered <number> as <display name> and state: reads licensed.
When register cannot finish, it prints the registration id and resume with: laidline licence fetch <registration id> and stops: on review (with the reason and what to do), on a pending payment, or when the wait runs out. Like
register, licence fetch exits 0 when it stops waiting, so a script checks laidline licence status afterwards.
Questions about a review go to support@laidline.com.
What registration changes:
- Within the month's allowance (1,200 credits on the free plan), new marks carry your registered number and your private payload key, and read as "registered to" your name. Over it, the rest of that UTC month's marks are shared marks again; they return to private on the 1st. Marking never stops.
- Paid plans raise the monthly credits and the number of signing keys a licence binds: Lite 15,000 credits and 1
key, Starter 150,000 and 2, Growth 1,000,000 and 5, Scale 10,000,000 and 10 (
--plan lite,starter,growth,scale;--interval yearbills yearly). Enterprise is sold by contract. Prices are on laidline.com/licensing. - Renewal is automatic. A licence covers the period you pay for plus 30 days' grace, and the deployment fetches
the renewed one on its own.
licence.auto_fetch: falseturns that off for an air-gapped host, which you renew withlaidline licence install(Automatic renewal). The deployment signs each fetch with its registered key; a rotated key must be bound before it can fetch, by runninglaidline registeragain (The documents proof). - Detection and verification stay free in every state.
The monthly usage report
A registered laidline serve sends one signed usage report a month to the issuing service: the month, the credits
and the counts of private and shared marks per medium, your payload key fingerprints and the product version. It
never holds media, prompts, email addresses or file names, and marking never waits on it. Turn it off with:
issuer: usage_report: false
laidline usage prints the same statement locally; laidline usage report --month YYYY-MM sends a completed month by
hand. See The usage report.
5. Set LAIDLINE_ENV=production
export LAIDLINE_ENV=production
What just happened: an unset LAIDLINE_ENV already means production for every relaxed check: an inline payload-key
secret, a key file readable by other users, the published test secret and extra trust roots are refused or ignored
unless LAIDLINE_ENV=development is set exactly. Setting production explicitly adds one more refusal: the local PEM
signing backend that laidline init writes. A host that still has the development key configured then stops at start
with signing.backend=local is not allowed in production, instead of signing with it. Set the variable in the
service definition of every production host (Configuration).
6. Turn on the security settings
The sidecar's defaults are safe for one machine. Before its port is reachable from anywhere else, set an inbound token and decide what to expose.
Bind. laidline serve listens on 127.0.0.1 unless you pass --host 0.0.0.0, and its first log line says which:
... INFO laidline.cli listening on 0.0.0.0:8080 (all interfaces)
A container needs --host 0.0.0.0; publish its port to the network that should reach it, not to the internet.
Inbound token. Name an environment variable in laidline.yaml and put a token of at least 32 characters in it:
server: mark_token_env: LAIDLINE_MARK_TOKEN
export LAIDLINE_MARK_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
Restart the sidecar; its start-up log says inbound token on. A request without the token is refused:
curl -s -F file=@in.png localhost:8080/v1/mark/image
Expected output:
{"detail":{"reason":"unauthorized","detail":"a valid Authorization: Bearer token is required (server.mark_token_env)"}}
What just happened: every route except /healthz, /readyz, /metrics, /detect, /v1/assets/* and /admin/*
now needs Authorization: Bearer <token>: the mark routes, the job routes, the adapter rules, the OpenAI-compatible
proxy and the generated API docs.
/v1/assets/* stays open because its URLs are unguessable links that your end users' browsers fetch. The variable is
read on every request, so you rotate the token without a restart. Clients send it as LAIDLINE_API_KEY
(MarkClient puts it in the Authorization header). The sidecar removes the token before it proxies a request, so a
proxied upstream that needs a key gets it from upstream.auth: env and the variable upstream.api_key_env names
(OpenAI-compatible proxy).
Upload cap. server.max_upload_bytes is 64 MiB (67,108,864 bytes) by default for every request body: mark and
detect uploads, proxy and adapter requests. Above it the mark, proxy and adapter routes answer 413 upload_too_large
and /detect answers 413; a request whose Content-Length is already over the cap is refused before its body is read,
and a body sent without a length is cut at the cap as it arrives. Cap request bodies at your reverse proxy as well, as
a second line of defence. Video uploads have their own cap, jobs.max_upload_bytes (2 GiB). Lower the cap if your
outputs are small.
Admin token. /admin/reload, /admin/evidence/status and /admin/evidence/export exist only while
LAIDLINE_ADMIN_TOKEN holds at least 32 characters; unset, every /admin path answers 404. Set it only where you use
those routes, and keep /admin reachable from your own network only (Configuration).
Metrics and detect. /metrics has no token and shares the port; keep it on your internal network or behind your
reverse proxy. /detect is open by design (detection is free) and limited to 60 requests a minute per client address
(detect.rate_limit).
7. Allow the outbound connections
Laidline sends nothing you did not configure. A production host needs these, and nothing else:
| Destination | Port | Used for | When |
|---|---|---|---|
timestamp.digicert.com, fallback timestamp.sectigo.com |
80 (HTTP) | RFC 3161 timestamps on every manifest and checkpoint | every mark |
| Your KMS or Vault endpoint | 443 | signing | every mark |
| Your S3 bucket or Postgres database | 443 or 5432 | the evidence sink | every mark |
issue.laidline.com |
443 | registration, licence renewal, the monthly usage report | once a day at most |
directory.laidline.com |
443 | laidline issuer refresh (the issuer directory) |
when you run it |
huggingface.co |
443 | AudioSeal weights | only when weights are missing; none after laidline models fetch |
dl.fbaipublicfiles.com |
443 | VideoSeal and PixelSeal weights | only when weights are missing; none after laidline models fetch |
The host in LAIDLINE_MODELS_MIRROR |
443 | model weights from a Laidline mirror | only if you set it, tried before the two rows above |
Timestamps are RFC 3161 over plain HTTP: the tokens are signed, so TLS adds nothing. Laidline ships the public roots
of both default timestamp servers (signing.tsa.roots_path: builtin) and checks every checkpoint token against them.
To use another timestamp server, set signing.tsa.url and save its root certificates as one PEM file for
signing.tsa.roots_path; until you do, its tokens are stored unverified and the server logs a warning at start
(Certificates). signing.tsa.required: false signs without a timestamp when none answers,
at the cost of grade B for the manifest instead of A. An air-gapped host sets LAIDLINE_OFFLINE=1 (no model downloads,
no licence fetch) and licence.auto_fetch: false.
8. Check readiness and health
curl -s localhost:8080/healthz
Expected output:
{"status":"ok"}
curl -s localhost:8080/readyz
Expected output once the start-up self-test has passed:
{"ready":true,"error":null}
What just happened: /healthz answers as soon as the process serves: use it as the liveness probe. /readyz turns
ready only after a self-test mark succeeded: the signing key, the timestamp server and the evidence sink all worked.
Use it as the readiness probe, so a host joins the load balancer only once it can mark.
While the self-test fails, /readyz answers 503 with a short category, and the server log has the full message:
{"ready":false,"error":"tsa_unavailable: MarkError"}
The category is <reason>: MarkError for a refused mark (tsa_unavailable, signer_unavailable),
self_test_failed: <error class> for anything else, or startup_failed when the configuration started without a
usable signing key under on_mark_failure: fail_open. The probe retries the self-test 30 seconds after a failure,
backing off to 15 minutes while it keeps failing, so a network that comes back turns it ready without a restart. A
change to signing.tsa needs a restart: SIGHUP (or POST /admin/reload) re-reads only the signing keys. The probe
that runs a retry answers only when the self-test finishes, which includes the timestamp client's own timeout; give
the readiness probe a timeout of 10 seconds or more, or accept that one probe times out. Under
on_mark_failure: fail_open an unreachable timestamp server does not hold readiness back, because the self-test
returns the image unmarked. Up to 10 self-test marks per UTC day are not counted as credits; a server that restarts
or retries more often than that pays for the extra ones.
/metrics gives Prometheus counters for alerting: laidline_marks_total{modality,status},
laidline_detects_total{verdict}, laidline_mark_seconds, laidline_jobs_queued and laidline_jobs_total{status}.
9. Add more hosts
One organisation registers once. Every further host of the same deployment carries a copy of the registered host's identity instead of registering again:
laidline.yaml(with itswatermark.issuer_id) andpayload-k1.key;- the signing key: access to the same KMS key (or Vault key), plus
certs/; licence.d/andissuer-certificate.json;- the same evidence sink as
sinks[0](step 3).
cp -a laidline.yaml payload-k1.key certs licence.d issuer-certificate.json /path/to/the/new/host/config/
What just happened: cp -a keeps the key files at mode 0600 and the directories at 0700. The licence binds the
signing key of the host that registered, so a host with its own key reads licence_invalid:not_bound and makes shared
marks; a host whose watermark.issuer_id differs from the certificate's numbers keeps making shared marks and
laidline licence status says set watermark.issuer_id: N in laidline.yaml and reload. A second register from the
same email domain is refused at once on the one-key plans (registered_free, lite) with the hint to copy the
identity; on plans that bind more keys it goes to review, and the second host registers with --plan starter,
growth or scale (More than one host).
Check it worked
On each host, with the sidecar running:
laidline licence status
The lines that show a production deployment:
state: licensed ... marks this month: private <n>, shared 0 ... issuer: registered <number> as <display name> ...
Then mark a file and read a copy without its manifest, as in the Quickstart: the watermark now reads as "registered to" your name, grade B once it matches the shared evidence log (Grading).
Limits
- Registration needs network access to
issue.laidline.comand an email at your domain; registered names are always published beside the number. - An S3 evidence sink costs three writes per mark, in two sequential round trips; for high volume, Postgres is the faster primary (Benchmarks).
- A manifest without a timestamp grades B, not A; a certificate outside the C2PA trust list reads as valid but not trusted to other validators (Grading).
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
signing.backend=local is not allowed in production |
LAIDLINE_ENV=production with the development key still configured |
Finish step 1, or for a host that must sign with a local key set signing.local.allow_in_production: true. |
error: registration needs the Laidline customer licence agreement <version> at <url>; ... |
register ran without --accept-terms outside a terminal |
Read the agreement, then run again with --accept-terms. |
in review (<reason>): Laidline finishes it by hand |
The registration waits for a manual step: for example daily_cap (the day's free registrations are used up), manual_payments (a paid plan, invoiced before approval) or a second registration from your domain |
Follow the hint it prints; resume with laidline licence fetch <registration id>. |
state: licence_invalid:not_bound |
The licence binds another host's signing key | Copy the whole identity (step 9), or point this host at the registered KMS key. |
set watermark.issuer_id: N in laidline.yaml and reload; marks stay shared until then |
The configured issuer number is neither the registered number nor the trial number the certificate lists | Set watermark.issuer_id to N and reload. |
{"ready":false,"error":"tsa_unavailable: MarkError"} |
No timestamp server answered the self-test | Allow outbound HTTP to the timestamp servers (step 7); the probe retries on its own. |
{"ready":false,"error":"signer_unavailable: MarkError"} |
The signing key could not sign: KMS or Vault unreachable, or a permission missing | Check kms:Sign and kms:GetPublicKey (or the Vault token and policy); the server log has the full message. |
401 with a valid Authorization: Bearer token is required (server.mark_token_env) |
The client sent no token, or the wrong one | Set LAIDLINE_API_KEY on the client to the value of the variable server.mark_token_env names. |
413 with upload_too_large |
The upload is larger than server.max_upload_bytes |
Raise server.max_upload_bytes, or send video to /v1/mark/video. |
object_lock is true but the Object Lock configuration of bucket ... could not be read |
The bucket has no Object Lock, or the role lacks s3:GetBucketObjectLockConfiguration |
Check the IAM policy first, then the bucket (Evidence log). |
A second host reads your registered marks as grade D with registry: missing |
Each host writes its own file sink | Give every host the same evidence.sinks[0] (step 3). |
Reference
- Certificates and keys: the leaf profile, Vault and OpenBao, signing requests, key rotation
- Evidence log: sinks, append-only guarantees, checkpoints and the export bundle
- Licensing: plans, credits, licence states and renewal
- Configuration: the
signing,evidence,server,adminandissuersections - HTTP API: every route the sidecar serves
- Compliance paperwork: the Article 50 export from the evidence log