# CopyObject

CopyObject makes a new object from one that is already stored, without the bytes ever leaving
STRG - nothing is downloaded and uploaded again. The source can sit in the same bucket or in
another bucket in the same region. It is the building block behind renaming an object (a copy,
then a [DeleteObject](https://docs.strg.com/s3-api/DeleteObject) of the original), changing its metadata, and bringing
back an earlier version as the current one.

For a large object, [UploadPartCopy](https://docs.strg.com/s3-api/UploadPartCopy) copies it in parts within a multipart
upload.

**Info: Good to know on STRG**

- A copy stays within one region: the source and the destination are both buckets in the
  endpoint's region.
- One access key covers both ends of the copy. It holds **Read objects** as well as **Write
  objects**, and its buckets and object prefix apply to the source just as they do to the
  destination.
- `x-amz-tagging`, `x-amz-checksum-algorithm`, `If-Match`, `If-None-Match`, event holds, SSE-KMS
  and `x-amz-grant-*` get a `501 NotImplemented` answer, so a client knows straight away.
- `x-amz-tagging-directive` is welcome and has nothing to do: objects carry no tags.
- `x-amz-acl` is `private` or `public-read`, matching the destination bucket - access is set per
  bucket, so every object in it shares the bucket's setting.
- `x-amz-expected-bucket-owner` and `x-amz-source-expected-bucket-owner` are welcome and have
  nothing to check: a key only ever reaches buckets of its own account.
- A destination key with a `.` or `..` path segment (`a/../b`) is answered `400 InvalidURI`, and
  nothing is stored.

## When you'd use it

- **Renaming and reorganizing.** A photo app that receives uploads under `incoming/` moves each
  one to `photos/2024/` once it has been processed: a CopyObject to the new key, then a
  DeleteObject of the old one.
- **Fixing metadata.** A static site's assets went up with the wrong `Content-Type`, or a longer
  `Cache-Control` would suit them better. Copying each object onto itself with
  `x-amz-metadata-directive: REPLACE` sets the new headers without re-uploading anything.
- **Bringing back an earlier version.** Every bucket keeps the versions of its objects, so an
  agent that overwrote the notes file it keeps its memory in can copy yesterday's version onto
  the same key, and that version is current again.
- **Promoting a release.** A deploy script copies a tested build from a private `staging` bucket
  into the public bucket its users download from, both in the same region, without the files
  passing through the CI machine.

## Request

```http
PUT /photos/2024/cat-copy.jpg HTTP/1.1
Host: my-bucket.s3.eu-central-1.strg.com
Authorization: AWS4-HMAC-SHA256 Credential=...
x-amz-date: 20260926T120000Z
x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-copy-source: /my-bucket/photos/2024/cat.jpg
```

Path-style, the same request is `PUT /my-bucket/photos/2024/cat-copy.jpg` on
`s3.eu-central-1.strg.com` - [Addressing](https://docs.strg.com/s3-api/overview#addressing) has both styles.

The request has no body. Every `x-amz-*` header, `x-amz-copy-source` included, is part of the
signature; the AWS CLI and SDKs take care of that.

### Permissions

A copy reads one object and writes another, so the access key needs both of these:

| Permission | Dashboard label | For |
| --- | --- | --- |
| `objects:write` | **Write objects** | Writing the destination object |
| `objects:read` | **Read objects** | Reading the source object |

For a key that reaches certain buckets or an object prefix, the destination bucket and key
**and** the source bucket and key all fall within them; a copy reaching outside them is answered
`403 AccessDenied`.

Setting Object Lock on the copy takes one more permission per header:

| Header | Also needs |
| --- | --- |
| `x-amz-object-lock-mode` or `x-amz-object-lock-retain-until-date` | **Set retention** (`object-retention:write`) |
| `x-amz-object-lock-legal-hold` | **Set legal holds** (`object-legal-hold:write`) |

[Access keys](https://docs.strg.com/access-keys) has the details.

### URI parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Bucket` | String | Yes | The destination bucket. |
| `Key` | String | Yes | The destination object key. A key with a `.` or `..` path segment is answered `400 InvalidURI`. |

CopyObject takes no query parameters. A query parameter S3 does not define for CopyObject gets a
`501 NotImplemented` answer.

### Request headers

The headers below are every one S3 defines for CopyObject, each with what STRG does with it.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `x-amz-copy-source` | String | Yes | The source, as `my-bucket/photos/2024/cat.jpg` (a leading `/` is optional), the key URL-encoded. Appending `?versionId=VersionId` copies that version; without it, the current version is copied. It travels as a header: in a presigned URL's query string it is answered `501 NotImplemented`. A value that does not name a valid bucket and a non-empty key is answered `400 InvalidArgument`. |
| `x-amz-metadata-directive` | String | No | `COPY` (the default) keeps the source's metadata and content headers; `REPLACE` takes the ones in this request instead. |
| `x-amz-meta-*` | String | No | User metadata for the copy. Applied with `x-amz-metadata-directive: REPLACE`. |
| `Cache-Control` | String | No | Stored with the copy. Applied with `REPLACE`. |
| `Content-Disposition` | String | No | Stored with the copy. Applied with `REPLACE`. |
| `Content-Encoding` | String | No | Stored with the copy. Applied with `REPLACE`. |
| `Content-Language` | String | No | Stored with the copy. Applied with `REPLACE`. |
| `Content-Type` | String | No | Stored with the copy. Applied with `REPLACE`. |
| `Expires` | Timestamp | No | Stored with the copy. Applied with `REPLACE`. |
| `x-amz-copy-source-if-match` | String | No | Copies only if the source's ETag matches. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-copy-source-if-none-match` | String | No | Copies only if the source's ETag differs. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-copy-source-if-modified-since` | Timestamp | No | Copies only if the source changed after this time. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-copy-source-if-unmodified-since` | Timestamp | No | Copies only if the source has not changed since this time. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `If-Match` | String | No | STRG answers `501 NotImplemented`. |
| `If-None-Match` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-acl` | String | No | `private` or `public-read`, matching the destination bucket's [Bucket Protection](https://docs.strg.com/getting-started-interactive) (`private` for a protected bucket). Any other canned ACL is answered `501 NotImplemented`. Access is set per bucket, never per object. |
| `x-amz-grant-full-control` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-grant-read` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-grant-read-acp` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-grant-write-acp` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-tagging` | String | No | STRG answers `501 NotImplemented`: objects carry no tags. |
| `x-amz-tagging-directive` | String | No | Welcome, whatever its value, and has nothing to do: objects carry no tags. |
| `x-amz-object-annotation-directive` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-checksum-algorithm` | String | No | STRG answers `501 NotImplemented`. The copy keeps the source's checksum algorithm, if it has one. |
| `x-amz-server-side-encryption` | String | No | `AES256` - server-side encryption with STRG-managed keys. `aws:kms` and `aws:kms:dsse` are answered `501 NotImplemented`. |
| `x-amz-server-side-encryption-aws-kms-key-id` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-server-side-encryption-context` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-server-side-encryption-bucket-key-enabled` | Boolean | No | STRG answers `501 NotImplemented`. |
| `x-amz-server-side-encryption-customer-algorithm` | String | No | `AES256`, to encrypt the copy with your own key (SSE-C). |
| `x-amz-server-side-encryption-customer-key` | String | No | The base64 key to encrypt the copy with (SSE-C). |
| `x-amz-server-side-encryption-customer-key-MD5` | String | No | The base64 MD5 digest of that key. |
| `x-amz-copy-source-server-side-encryption-customer-algorithm` | String | No | `AES256`, when the source is encrypted with your own key. |
| `x-amz-copy-source-server-side-encryption-customer-key` | String | No | The key the source was encrypted with. |
| `x-amz-copy-source-server-side-encryption-customer-key-MD5` | String | No | The base64 MD5 digest of that key. |
| `x-amz-object-lock-mode` | String | No | `GOVERNANCE` or `COMPLIANCE`, for a destination bucket with Object Lock enabled. Needs **Set retention**. [Object Lock](https://docs.strg.com/object-locks) explains the modes. |
| `x-amz-object-lock-retain-until-date` | Timestamp | No | When the copy's retention ends. Needs **Set retention**. |
| `x-amz-object-lock-legal-hold` | String | No | `ON` or `OFF`. Needs **Set legal holds**. |
| `x-amz-object-lock-event-hold` | String | No | STRG answers `501 NotImplemented`. |
| `x-amz-object-lock-event-hold-duration-days` | Integer | No | STRG answers `501 NotImplemented`. |
| `x-amz-object-lock-event-hold-duration-years` | Integer | No | STRG answers `501 NotImplemented`. |
| `x-amz-storage-class` | String | No | STRG has one storage class, `STANDARD`. |
| `x-amz-website-redirect-location` | String | No | STRG does not host bucket websites, so this header has no effect to count on. |
| `x-amz-request-payer` | String | No | Has nothing to do: STRG has no Requester Pays buckets. |
| `x-amz-expected-bucket-owner` | String | No | Welcome, and has nothing to check: an access key only ever reaches its own account's buckets. |
| `x-amz-source-expected-bucket-owner` | String | No | Welcome, and has nothing to check, as above. |

### Request body

None.

## Response

A successful copy answers `200 OK` with a `CopyObjectResult` document.

### Response headers

| Name | Description |
| --- | --- |
| `x-amz-version-id` | The version ID of the new copy. Buckets are always versioned, so every copy creates a new version. |
| `x-amz-copy-source-version-id` | The version ID of the source that was copied. |
| `x-amz-server-side-encryption` | `AES256` when the copy is encrypted with STRG-managed keys. |
| `x-amz-server-side-encryption-customer-algorithm` | `AES256` when the copy is encrypted with your own key. |
| `x-amz-server-side-encryption-customer-key-MD5` | The MD5 digest of your key, for SSE-C. |
| `x-amz-request-id` | The request ID, handy when contacting support. |

Version IDs are opaque strings, best stored and sent back exactly as they are.

`x-amz-server-side-encryption-aws-kms-key-id`, `x-amz-server-side-encryption-context`,
`x-amz-server-side-encryption-bucket-key-enabled` and `x-amz-request-charged` are not part of the
answer, and `x-amz-expiration` is not something to count on.

### Response body

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult>
  <ETag>"85f30635602dc09bd85957a6e82a2c21"</ETag>
  <LastModified>2026-09-26T12:00:00.000Z</LastModified>
</CopyObjectResult>
```

| Element | Description |
| --- | --- |
| `ETag` | The entity tag of the new copy. |
| `LastModified` | When the copy was created. |

## Errors

These are the answers a copy can get instead of `200 OK`, and the situation behind each.

| Code | HTTP status | When |
| --- | --- | --- |
| `NoSuchKey` | 404 | The source object, or the version named by `versionId`, does not exist. |
| `NoSuchBucket` | 404 | The destination bucket does not exist, or it is in a region other than the endpoint's. |
| `InvalidArgument` | 400 | `x-amz-copy-source` does not name a valid bucket and key. |
| `InvalidURI` | 400 | The destination key contains a `.` or `..` path segment, such as `a/../b`. Nothing is stored. |
| `NotImplemented` | 501 | The request carries a header or query parameter STRG does not offer - [Request headers](https://docs.strg.com/s3-api/CopyObject#request-headers) lists each one. |
| `AccessDenied` | 403 | The access key lacks **Write objects** or **Read objects**, or the Object Lock permission a lock header needs, or the source or destination is outside its buckets or object prefix. Also an expired presigned URL, and an unsigned `x-amz-*` header. |
| `InvalidAccessKeyId` | 403 | The access key does not exist, has expired or been revoked, or was created moments ago and is not yet usable in this region, where you already store data. It becomes usable shortly after. |
| `SignatureDoesNotMatch` | 403 | The request signature does not match. |
| `RequestTimeTooSkewed` | 403 | The request time is more than 15 minutes from the server's. |
| `AuthorizationHeaderMalformed` | 400 | The signature's credential scope names a region other than the endpoint's. |
| `SlowDown` | 503 | Too many requests at once. The AWS CLI and SDKs retry after the `Retry-After` interval. |
| `ServiceUnavailable` | 503 | A temporary failure, which a retry of the request gets past. |

## Differences from Amazon S3

Each row is something STRG does its own way, and what that means for a copy.

| Aspect | Amazon S3 | STRG |
| --- | --- | --- |
| Source in another region | Supported | A copy stays within one region. Moving an object between regions is a download from one regional endpoint and an upload to the other. |
| Bucket in another region | `301` redirect with `x-amz-bucket-region` | `404 NoSuchBucket`. The bucket's own regional endpoint reaches it. |
| Permissions | `s3:GetObject` on the source, `s3:PutObject` on the destination | One access key holding both **Read objects** and **Write objects**; its buckets and object prefix apply to the source and the destination |
| Tags | `x-amz-tagging` and `x-amz-tagging-directive` copy or replace the tag set | Objects carry no tags: `x-amz-tagging` is answered `501`, and `x-amz-tagging-directive` has nothing to do |
| Checksum algorithm | `x-amz-checksum-algorithm` can change it | `501 NotImplemented`. The copy keeps the source's checksum |
| `If-Match` / `If-None-Match` on the destination | Supported | `501 NotImplemented` |
| ACLs | Any canned ACL and `x-amz-grant-*` | `private` or `public-read`, matching the destination bucket; grants are answered `501` |
| SSE-KMS | Supported | `501 NotImplemented`. `AES256` and SSE-C both encrypt the copy |
| Event holds, annotations | Supported | `501 NotImplemented` |
| `x-amz-copy-source` in a presigned URL's query | Accepted | `501 NotImplemented`. It travels as a header |
| `x-amz-expected-bucket-owner`, `x-amz-source-expected-bucket-owner` | Checked against the bucket owner | Welcome, with nothing to check |
| Keys with a `.` or `..` path segment (`a/../b`) | Supported | `400 InvalidURI` as the destination key; nothing is stored |

## Examples

Copying `photos/2024/cat.jpg` to `photos/2024/cat-copy.jpg` in the same bucket:

**AWS CLI**

```bash
aws s3api copy-object \
  --bucket my-bucket \
  --key photos/2024/cat-copy.jpg \
  --copy-source my-bucket/photos/2024/cat.jpg \
  --endpoint-url https://s3.eu-central-1.strg.com \
  --region eu-central-1
```

**Python (boto3)**

```python
import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.eu-central-1.strg.com",
    region_name="eu-central-1",
)

response = s3.copy_object(
    Bucket="my-bucket",
    Key="photos/2024/cat-copy.jpg",
    CopySource={"Bucket": "my-bucket", "Key": "photos/2024/cat.jpg"},
)
print(response["CopyObjectResult"]["ETag"], response.get("VersionId"))
```

**JavaScript (AWS SDK v3)**

```javascript
import { CopyObjectCommand, S3Client } from "@aws-sdk/client-s3";

const client = new S3Client({
  endpoint: "https://s3.eu-central-1.strg.com",
  region: "eu-central-1",
});

const response = await client.send(
  new CopyObjectCommand({
    Bucket: "my-bucket",
    Key: "photos/2024/cat-copy.jpg",
    // A key with characters other than letters, digits and / . - _ goes in URL-encoded
    CopySource: "my-bucket/photos/2024/cat.jpg",
  }),
);
console.log(response.CopyObjectResult?.ETag, response.VersionId);
```

Changing an object's metadata in place is a copy onto itself with `REPLACE`:

**AWS CLI**

```bash
aws s3api copy-object \
  --bucket my-bucket \
  --key photos/2024/cat.jpg \
  --copy-source my-bucket/photos/2024/cat.jpg \
  --metadata-directive REPLACE \
  --content-type image/jpeg \
  --cache-control "max-age=86400" \
  --metadata photographer=alex \
  --endpoint-url https://s3.eu-central-1.strg.com \
  --region eu-central-1
```

**Python (boto3)**

```python
import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.eu-central-1.strg.com",
    region_name="eu-central-1",
)

s3.copy_object(
    Bucket="my-bucket",
    Key="photos/2024/cat.jpg",
    CopySource={"Bucket": "my-bucket", "Key": "photos/2024/cat.jpg"},
    MetadataDirective="REPLACE",
    ContentType="image/jpeg",
    CacheControl="max-age=86400",
    Metadata={"photographer": "alex"},
)
```

**JavaScript (AWS SDK v3)**

```javascript
import { CopyObjectCommand, S3Client } from "@aws-sdk/client-s3";

const client = new S3Client({
  endpoint: "https://s3.eu-central-1.strg.com",
  region: "eu-central-1",
});

await client.send(
  new CopyObjectCommand({
    Bucket: "my-bucket",
    Key: "photos/2024/cat.jpg",
    CopySource: "my-bucket/photos/2024/cat.jpg",
    MetadataDirective: "REPLACE",
    ContentType: "image/jpeg",
    CacheControl: "max-age=86400",
    Metadata: { photographer: "alex" },
  }),
);
```

Bringing back an earlier version as the current one is a copy of that version onto the same key.
[ListObjectVersions](https://docs.strg.com/s3-api/ListObjectVersions) lists the version IDs:

**AWS CLI**

```bash
aws s3api copy-object \
  --bucket my-bucket \
  --key photos/2024/cat.jpg \
  --copy-source "my-bucket/photos/2024/cat.jpg?versionId=$VERSION_ID" \
  --endpoint-url https://s3.eu-central-1.strg.com \
  --region eu-central-1
```

**Python (boto3)**

```python
import os

import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.eu-central-1.strg.com",
    region_name="eu-central-1",
)

s3.copy_object(
    Bucket="my-bucket",
    Key="photos/2024/cat.jpg",
    CopySource={
        "Bucket": "my-bucket",
        "Key": "photos/2024/cat.jpg",
        "VersionId": os.environ["VERSION_ID"],
    },
)
```

**JavaScript (AWS SDK v3)**

```javascript
import { CopyObjectCommand, S3Client } from "@aws-sdk/client-s3";

const client = new S3Client({
  endpoint: "https://s3.eu-central-1.strg.com",
  region: "eu-central-1",
});

await client.send(
  new CopyObjectCommand({
    Bucket: "my-bucket",
    Key: "photos/2024/cat.jpg",
    CopySource: `my-bucket/photos/2024/cat.jpg?versionId=${encodeURIComponent(process.env.VERSION_ID)}`,
  }),
);
```

## Related

- [PutObject](https://docs.strg.com/s3-api/PutObject)
- [GetObject](https://docs.strg.com/s3-api/GetObject)
- [HeadObject](https://docs.strg.com/s3-api/HeadObject)
- [DeleteObject](https://docs.strg.com/s3-api/DeleteObject)
- [UploadPartCopy](https://docs.strg.com/s3-api/UploadPartCopy)
- [ListObjectVersions](https://docs.strg.com/s3-api/ListObjectVersions)
- [Object Lock](https://docs.strg.com/object-locks)
- [Access keys](https://docs.strg.com/access-keys)
- [S3 API overview](https://docs.strg.com/s3-api/overview)
