How to configure AWS S3 Bucket for EC2 for videos – Part – 1

Yes. Based on your constraints, I would simplify the implementation considerably.

Final scope

Private S3 bucket
      ↑ ↓
Direct browser upload/download using presigned URLs
      ↑
Application on EC2
      ↑
EC2 IAM Role

NO AWS credentials for developers
NO AWS credentials for application users
NO video stored on EC2
NO CloudFront
NO VPC Endpoint
NO S3 Versioning
NO public bucket

Presigned URLs are designed exactly for this: the application can grant temporary upload/download access without giving the user AWS credentials. The URL can only perform actions permitted to the IAM principal that generated it. AWS Documentation

There is one thing I would fix before implementation, though.

0. Important: your bucket name videos.wizbrand.com

AWS currently documents a TLS limitation for S3 virtual-hosted URLs: S3’s wildcard SSL certificate matches bucket names without dots, and AWS says bucket names containing periods are valid but not recommended for use cases other than static website hosting. AWS Documentation

Your bucket:

videos.wizbrand.com

would normally produce a URL resembling:

https://videos.wizbrand.com.s3.ap-south-1.amazonaws.com/...

Therefore, if this bucket is still empty, I recommend changing it now to something like:

wizbrand-videos-prod

or:

videos-wizbrand-com

The bucket name does not need to be videos.wizbrand.com. Since you aren’t using CloudFront, end users won’t see a pretty custom hostname anyway.

If you absolutely want to keep videos.wizbrand.com, JavaScript AWS SDK v3 supports:

const s3 = new S3Client({
    region: process.env.AWS_REGION,
    forcePathStyle: true,
});

AWS documents forcePathStyle for SDK v3. AWS Documentation

For the rest below, I’ll keep your current name:

videos.wizbrand.com

PART 1 — DevOps implementation

I’d implement the DevOps side in this order.

Step 1 — Verify existing bucket

First find its region:

aws s3api get-bucket-location \
  --bucket videos.wizbrand.com

Check Block Public Access:

aws s3api get-public-access-block \
  --bucket videos.wizbrand.com

Check versioning:

aws s3api get-bucket-versioning \
  --bucket videos.wizbrand.com

You want versioning to return either nothing or not be Enabled.

If versioning was never enabled, leave it alone.

You specifically don’t want versioning, so don’t create a Terraform aws_s3_bucket_versioning resource.


Step 2 — Enable complete Block Public Access

The bucket must remain private.

AWS recommends enabling all four S3 Block Public Access controls. AWS Documentation

CLI:

aws s3api put-public-access-block \
  --bucket videos.wizbrand.com \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Verify:

aws s3api get-public-access-block \
  --bucket videos.wizbrand.com

Expected:

{
  "PublicAccessBlockConfiguration": {
    "BlockPublicAcls": true,
    "IgnorePublicAcls": true,
    "BlockPublicPolicy": true,
    "RestrictPublicBuckets": true
  }
}

The presigned URLs will still work. You do not need to make the bucket public for presigned access.


Step 3 — Configure Object Ownership

Use:

BucketOwnerEnforced

This disables ACLs entirely and makes permissions policy-based. AWS recommends this model for modern S3 usage. AWS Documentation

CLI:

aws s3api put-bucket-ownership-controls \
  --bucket videos.wizbrand.com \
  --ownership-controls '{
    "Rules": [
      {
        "ObjectOwnership": "BucketOwnerEnforced"
      }
    ]
  }'

Verify:

aws s3api get-bucket-ownership-controls \
  --bucket videos.wizbrand.com

Your developers should therefore never send:

ACL: public-read
ACL: private
ACL: bucket-owner-full-control

No ACL is required.


Step 4 — Explicitly configure S3 encryption

S3 already encrypts new objects by default, but I would explicitly manage the setting.

For now:

SSE-S3
AES256

is sufficient.

CLI:

aws s3api put-bucket-encryption \
  --bucket videos.wizbrand.com \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "AES256"
        },
        "BucketKeyEnabled": false
      }
    ]
  }'

No KMS permissions are then required.


Step 5 — Establish the S3 object structure

DevOps and developers should agree on one structure.

I recommend:

tenants/
    {tenant_id}/
        users/
            {user_id}/
                videos/
                    {video_id}/
                        original.mp4

Example:

tenants/tenant-a7d91/
users/user-a810/
videos/video-928ab/
original.mp4

Actual full key:

tenants/tenant-a7d91/users/user-a810/videos/video-928ab/original.mp4

The backend generates this.

Never allow the browser to say:

s3_key = "whatever/user/sends.mp4"

Step 6 — Create EC2 application IAM role

Create:

wizbrand-video-app-role

This role is for the application, not developers.

Trust relationship:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

CLI:

aws iam create-role \
  --role-name wizbrand-video-app-role \
  --assume-role-policy-document file://ec2-trust-policy.json

Step 7 — Create the S3 application IAM policy

Create:

wizbrand-video-s3-policy

I would start with this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VideoObjectAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectAttributes",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:AbortMultipartUpload",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": "arn:aws:s3:::videos.wizbrand.com/tenants/*"
    },
    {
      "Sid": "BucketMetadata",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation"
      ],
      "Resource": "arn:aws:s3:::videos.wizbrand.com"
    }
  ]
}

Notice something deliberately missing:

s3:ListBucket

I would not give it initially.

Your application’s database should determine:

Which videos does this user have?

rather than:

List everything under this S3 prefix.

That reduces S3 permissions.

Create policy:

aws iam create-policy \
  --policy-name wizbrand-video-s3-policy \
  --policy-document file://wizbrand-video-s3-policy.json

Attach:

aws iam attach-role-policy \
  --role-name wizbrand-video-app-role \
  --policy-arn arn:aws:iam::<AWS_ACCOUNT_ID>:policy/wizbrand-video-s3-policy

Step 8 — Create EC2 Instance Profile

Create:

aws iam create-instance-profile \
  --instance-profile-name wizbrand-video-app-instance-profile

Add role:

aws iam add-role-to-instance-profile \
  --instance-profile-name wizbrand-video-app-instance-profile \
  --role-name wizbrand-video-app-role

Then attach that instance profile to your EC2 instance.

Important: an EC2 instance normally has one IAM instance profile.

If your application EC2 already has an IAM role, don’t replace it blindly. Instead attach:

wizbrand-video-s3-policy

to the existing EC2 role.

That is usually cleaner.


Step 9 — Bucket policy: enforce HTTPS

Create:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::videos.wizbrand.com",
        "arn:aws:s3:::videos.wizbrand.com/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    }
  ]
}

Save as:

bucket-policy.json

Apply:

aws s3api put-bucket-policy \
  --bucket videos.wizbrand.com \
  --policy file://bucket-policy.json

Notice that there is no:

"Allow": {
  "Principal": "*"
}

The bucket remains private.


Step 10 — Configure CORS

This is required because the user’s browser will upload directly to S3.

Suppose your application frontend is:

https://app.wizbrand.com

Use:

[
  {
    "AllowedHeaders": [
      "*"
    ],
    "AllowedMethods": [
      "GET",
      "PUT",
      "HEAD"
    ],
    "AllowedOrigins": [
      "https://app.wizbrand.com"
    ],
    "ExposeHeaders": [
      "ETag"
    ],
    "MaxAgeSeconds": 3600
  }
]

Apply:

aws s3api put-bucket-cors \
  --bucket videos.wizbrand.com \
  --cors-configuration file://cors.json

If your real application is:

https://wizbrand.com

use that instead.

For multiple environments:

"AllowedOrigins": [
  "https://wizbrand.com",
  "https://staging.wizbrand.com"
]

Do not use:

"AllowedOrigins": ["*"]

unless there is genuinely no alternative.

ETag is exposed because it becomes useful for multipart video uploads.


Step 11 — Multipart upload cleanup

For video applications, multipart upload is worth supporting even if you don’t need it on day one.

If a user starts uploading 3 GB and abandons the browser after uploading 1 GB, the incomplete parts cost storage until cleaned up.

Create:

{
  "Rules": [
    {
      "ID": "AbortIncompleteVideoUploads",
      "Status": "Enabled",
      "Filter": {
        "Prefix": "tenants/"
      },
      "AbortIncompleteMultipartUpload": {
        "DaysAfterInitiation": 1
      }
    }
  ]
}

Apply:

aws s3api put-bucket-lifecycle-configuration \
  --bucket videos.wizbrand.com \
  --lifecycle-configuration file://lifecycle.json

This doesn’t delete completed videos.

It only cleans abandoned multipart uploads.


Step 12 — Test IAM role from EC2

SSH/SSM to the application EC2.

Do NOT configure:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY

Run:

aws sts get-caller-identity

It should show an assumed role similar to:

wizbrand-video-app-role

Test write without creating a local file:

echo "wizbrand-test" | \
aws s3 cp - \
s3://videos.wizbrand.com/tenants/test/users/test/videos/test/test.txt

Read:

aws s3 cp \
s3://videos.wizbrand.com/tenants/test/users/test/videos/test/test.txt -

Delete:

aws s3 rm \
s3://videos.wizbrand.com/tenants/test/users/test/videos/test/test.txt

Then verify something outside the approved prefix fails:

echo "test" | \
aws s3 cp - \
s3://videos.wizbrand.com/test.txt

Expected:

AccessDenied

Excellent. That proves the role is scoped correctly.


Step 13 — Terraform version

Since the bucket already exists, I wouldn’t attempt to recreate it.

You can manage its configuration independently:

variable "bucket_name" {
  type    = string
  default = "videos.wizbrand.com"
}

variable "allowed_origins" {
  type = list(string)

  default = [
    "https://app.wizbrand.com"
  ]
}

Public access

resource "aws_s3_bucket_public_access_block" "videos" {
  bucket = var.bucket_name

  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}

Ownership

resource "aws_s3_bucket_ownership_controls" "videos" {
  bucket = var.bucket_name

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

Encryption

resource "aws_s3_bucket_server_side_encryption_configuration" "videos" {
  bucket = var.bucket_name

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

CORS

resource "aws_s3_bucket_cors_configuration" "videos" {
  bucket = var.bucket_name

  cors_rule {
    allowed_headers = ["*"]

    allowed_methods = [
      "GET",
      "HEAD",
      "PUT"
    ]

    allowed_origins = var.allowed_origins

    expose_headers = [
      "ETag"
    ]

    max_age_seconds = 3600
  }
}

Multipart cleanup

resource "aws_s3_bucket_lifecycle_configuration" "videos" {
  bucket = var.bucket_name

  rule {
    id     = "abort-incomplete-video-uploads"
    status = "Enabled"

    filter {
      prefix = "tenants/"
    }

    abort_incomplete_multipart_upload {
      days_after_initiation = 1
    }
  }
}

Step 14 — Terraform IAM policy

data "aws_iam_policy_document" "video_s3_access" {
  statement {
    sid = "VideoObjectAccess"

    actions = [
      "s3:GetObject",
      "s3:GetObjectAttributes",
      "s3:PutObject",
      "s3:DeleteObject",
      "s3:AbortMultipartUpload",
      "s3:ListMultipartUploadParts"
    ]

    resources = [
      "arn:aws:s3:::${var.bucket_name}/tenants/*"
    ]
  }

  statement {
    sid = "BucketMetadata"

    actions = [
      "s3:GetBucketLocation"
    ]

    resources = [
      "arn:aws:s3:::${var.bucket_name}"
    ]
  }
}

resource "aws_iam_policy" "video_s3_access" {
  name   = "wizbrand-video-s3-policy"
  policy = data.aws_iam_policy_document.video_s3_access.json
}

If your EC2 already has a role:

variable "application_ec2_role_name" {
  type = string
}

resource "aws_iam_role_policy_attachment" "video_s3" {
  role       = var.application_ec2_role_name
  policy_arn = aws_iam_policy.video_s3_access.arn
}

This is probably what I would use in your environment.


Step 15 — Terraform HTTPS bucket policy

data "aws_iam_policy_document" "videos_bucket_policy" {
  statement {
    sid    = "DenyInsecureTransport"
    effect = "Deny"

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    actions = [
      "s3:*"
    ]

    resources = [
      "arn:aws:s3:::${var.bucket_name}",
      "arn:aws:s3:::${var.bucket_name}/*"
    ]

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "videos" {
  bucket = var.bucket_name
  policy = data.aws_iam_policy_document.videos_bucket_policy.json
}

That’s basically your entire AWS infrastructure requirement.


PART 2 — What DevOps gives the developers

Developers do not receive:

AWS console account
IAM User
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
S3 bucket credentials

You give them only application configuration:

AWS_REGION=ap-south-1
VIDEO_S3_BUCKET=videos.wizbrand.com

VIDEO_UPLOAD_URL_EXPIRY_SECONDS=900
VIDEO_DOWNLOAD_URL_EXPIRY_SECONDS=300
VIDEO_MAX_SIZE_BYTES=5368709120

Change the region to your actual bucket region.

For the dotted bucket workaround, if applicable:

S3_FORCE_PATH_STYLE=true

That’s configuration, not a secret.


PART 3 — Developer implementation

Developer architecture becomes extremely simple:

                         ┌─────────────────┐
                         │      User       │
                         └───────┬─────────┘
                                 │
                        Authenticate
                                 │
                                 ▼
                    ┌────────────────────┐
                    │ Application / EC2  │
                    │                    │
                    │ Check tenant       │
                    │ Check user         │
                    │ Generate S3 key    │
                    │ Generate URL       │
                    └───────┬────────────┘
                            │
                       IAM Role
                            │
                            ▼

UPLOAD:

User ───── presigned PUT ─────────────► S3

DOWNLOAD:

User ◄──── presigned GET ────────────── S3

DELETE:

User ──► Application ── DeleteObject ─► S3

Nothing passes through EC2 except API messages.


PART 4 — Developer AWS SDK initialization

For Node.js/TypeScript, for example:

import { S3Client } from "@aws-sdk/client-s3";

export const s3 = new S3Client({
  region: process.env.AWS_REGION,

  // Recommended only because your existing
  // bucket name contains dots.
  forcePathStyle: true,
});

Notice:

credentials: {
   accessKeyId: "...",
   secretAccessKey: "..."
}

is completely absent.

AWS SDK running on EC2 automatically obtains temporary credentials from the EC2 IAM role.


PART 5 — Generate upload URL

Backend:

import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const key =
  `tenants/${tenantId}/users/${userId}/videos/${videoId}/original.mp4`;

const command = new PutObjectCommand({
  Bucket: process.env.VIDEO_S3_BUCKET,
  Key: key,
  ContentType: "video/mp4"
});

const uploadUrl = await getSignedUrl(
  s3,
  command,
  {
    expiresIn: 900
  }
);

Then backend returns:

{
  "videoId": "87f37...",
  "uploadUrl": "https://..."
}

PART 6 — Browser uploads directly to S3

Frontend:

await fetch(uploadUrl, {
  method: "PUT",
  headers: {
    "Content-Type": file.type
  },
  body: file
});

Flow:

Laptop/browser
       │
       │ video bytes
       ▼
       S3

NOT:

Laptop
   ↓ video
EC2
   ↓ video
S3

That’s exactly what you wanted.


PART 7 — Generate download URL

Backend:

import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const command = new GetObjectCommand({
  Bucket: process.env.VIDEO_S3_BUCKET,
  Key: video.s3Key
});

const downloadUrl = await getSignedUrl(
  s3,
  command,
  {
    expiresIn: 300
  }
);

Return the URL.

The user then downloads directly:

Browser ◄──────── S3

AWS provides this exact presigned-download pattern in its current JavaScript SDK documentation. AWS Documentation


PART 8 — Delete

I recommend delete continue through EC2:

import { DeleteObjectCommand } from "@aws-sdk/client-s3";

await s3.send(
  new DeleteObjectCommand({
    Bucket: process.env.VIDEO_S3_BUCKET,
    Key: video.s3Key
  })
);

The backend should first execute something conceptually like:

SELECT *
FROM videos
WHERE id = :video_id
  AND tenant_id = :authenticated_tenant_id
  AND user_id = :authenticated_user_id;

Only if that succeeds should it call S3.

Do not give users presigned DELETE URLs.

There is no performance advantage worth the extra risk.


PART 9 — Database responsibility

S3 should not be your database.

Keep something like:

videos

id
tenant_id
user_id
s3_key
original_filename
content_type
size_bytes
status
created_at
updated_at

Example:

id          = video-81273
tenant_id   = tenant-123
user_id     = user-456

s3_key =
tenants/tenant-123/users/user-456/videos/video-81273/original.mp4

This is extremely important for multi-tenancy.


PART 10 — Developer security rule

The browser must never determine:

tenant_id
S3 key
S3 path

Authoritative tenant information must come from:

authenticated session / JWT

Bad:

const tenantId = req.body.tenantId;

Good:

const tenantId = req.auth.tenantId;
const userId   = req.auth.userId;

Then:

const key =
  `tenants/${tenantId}/users/${userId}/videos/${videoId}/original.mp4`;

This prevents:

Tenant A
     ↓
request Tenant B path
     ↓
download/delete Tenant B video

PART 11 — Revised responsibility split

RequirementDevOpsDeveloper
S3 bucket✅
Public access disabled✅
Object ownership✅
Encryption✅
Bucket policy✅
IAM EC2 role✅
IAM S3 policy✅
Attach role to EC2✅
S3 CORS✅Input
Multipart cleanup✅
Terraform✅
AWS credentialsNone givenNone
S3 key formatAgree✅ implement
Tenant authorization✅
User authorization✅
Video database table✅
Generate presigned upload URL✅
Direct browser → S3 upload✅
Generate presigned download URL✅
Direct S3 → browser download✅
DeleteObjectIAM permission✅
Multipart video uploadIAM permission✅
File-size/type validation✅
Cross-tenant security tests✅

Final implementation sequence

I would execute the project in this order:

DEVOPS
   │
   ├── 1. Decide dotted bucket name issue
   ├── 2. Verify bucket/versioning
   ├── 3. Block Public Access
   ├── 4. BucketOwnerEnforced
   ├── 5. SSE-S3 encryption
   ├── 6. CORS
   ├── 7. Multipart cleanup
   ├── 8. HTTPS bucket policy
   ├── 9. IAM application policy
   ├─ 10. Attach policy to EC2 IAM role
   └─ 11. Verify role using AWS CLI
          │
          ▼
       HANDOFF
          │
          ├── bucket name
          ├── region
          ├── key convention
          ├── URL expiry
          └── NO AWS credentials
                 │
                 ▼
            DEVELOPER
                 │
                 ├── POST /videos/upload
                 │       ↓
                 │   generate presigned PUT
                 │
                 ├── browser → S3
                 │
                 ├── GET /videos/{id}/download
                 │       ↓
                 │   presigned GET
                 │
                 ├── DELETE /videos/{id}
                 │       ↓
                 │   backend → S3 DeleteObject
                 │
                 └── Tenant security tests

This is the architecture I’d use for Wizbrand V1. It’s small, secure, inexpensive, and doesn’t prematurely add CloudFront, KMS, VPC endpoints, STS tenant roles, or other infrastructure you don’t currently need.