Ravindra BagaleCourses & study guides मराठी Track your progress

Project: Build a Course Video Platform with CloudFront and S3 (Source Code)

This is the CloudFront project. We use everything from What Is Amazon CloudFront to CloudFront HTTPS and Custom Domains and build a small Udemy-style course site: free preview videos anyone logged in can watch, paid lessons that play only through a short signed URL, thumbnails cached for a year, and an admin page that uploads straight to S3. Full Node.js source is below, with Flask and PHP signing versions. Download the full source code (zip).

What you'll learn in this chapter

  • Design: private S3 + OAC + signed URLs + free previews
  • Cache behaviors for /thumbs/*, /free/* and /videos/*
  • Full Node.js app: login, course list, signed playback, admin upload
  • Flask and PHP signing variants
  • Build, test (free, locked, expired) and clean up

1. The story: a teacher sells a course

Ravi records an AWS course: 40 videos. He wants the first video free so people can try. The rest only for students who paid ₹499. Students are all over India and abroad. If he puts the MP4 links on a public bucket, one student shares the link on Telegram and the course is free for everyone.

So: the bucket is private (OAC), paid videos need a signed link that dies in 10 minutes, and CloudFront serves everything from the nearest edge.

2. Architecture

Why do we need this?

The app server should decide who may watch, but it should never stream gigabytes of video itself.

What is it?

Playback flowStudent logs inNode app on EC2Opens a lessonapp checks: free or paid?Paid and not bought403 "Buy this course"Paid and boughtapp signs URL, 10 minBrowser plays fromvideos.yourdomain.com edgeEdge MISSfetch from private S3 via OACEdge HITserved from cache
Bytes go S3 → CloudFront → browser. The app only hands out links.
PartSettingWhy
S3 bucketyourname-course-media, Block Public Access on, prefixes free/, videos/, thumbs/Nothing public; prefixes map to behaviors
CloudFrontOAC origin, alternate domain videos.yourdomain.com, ACM cert (us-east-1), Price Class 200Fast in India and abroad, HTTPS
Behavior /thumbs/*CachingOptimized, unrestrictedThumbnails are public; upload with max-age 1 year
Behavior /free/*CachingOptimized, unrestrictedFree previews
Behavior /videos/*CachingOptimized, Restrict viewer access with key groupPaid lessons need a valid signature
Default (*)Restrict viewer accessAnything else stays locked
EC2 appNode.js 22, IAM role, private key in SSM SecureStringNo keys in code or Git

Ravindra Bagale's Tip

Signed URLs are cached like any other request: CloudFront removes the signature parameters from the cache key, so all buyers share one cached copy of lesson1.mp4.

3. Source code

Folder layout: server.js (routes), aws.js (S3, CloudFront, SSM), data.js (demo store; swap for RDS MySQL), public/admin.js (browser upload), policies as JSON. Download all files as a zip.

package.json

{
  "name": "course-video-platform",
  "version": "1.0.0",
  "description": "Udemy-style course videos with S3, CloudFront OAC and signed URLs - By Ravindra Bagale",
  "main": "server.js",
  "scripts": { "start": "node server.js" },
  "dependencies": {
    "@aws-sdk/client-cloudfront": "^3.600.0",
    "@aws-sdk/client-s3": "^3.600.0",
    "@aws-sdk/client-ssm": "^3.600.0",
    "@aws-sdk/cloudfront-signer": "^3.600.0",
    "@aws-sdk/s3-request-presigner": "^3.600.0",
    "bcryptjs": "^2.4.3",
    "express": "^4.19.2",
    "express-session": "^1.18.0"
  }
}

.env.example

AWS_REGION=ap-south-1
BUCKET=yourname-course-media
CDN_DOMAIN=videos.yourdomain.com
DIST_ID=E1ABCDEF2GHIJ
CF_KEY_PAIR_ID=K2JCJMDEHXQW5F
CF_PRIVATE_KEY_PARAM=/course/cf-private-key
SESSION_SECRET=change-me-to-a-long-random-string
SIGNED_MINUTES=10

server.js

// Course video platform - By Ravindra Bagale
// Login -> course list -> free preview plays from CloudFront; paid video gets a short signed URL.
const express = require('express');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const db = require('./data');
const aws = require('./aws');

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(express.static(__dirname + '/public'));
app.use(session({ secret: process.env.SESSION_SECRET || 'dev', resave: false, saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax' } }));

const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const page = (title, body) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>${esc(title)}</title><link rel="stylesheet" href="/style.css"><header><a href="/">Courses</a> <span id="who"></span></header><main>${body}</main>`;
const me = req => req.session.uid && db.getUser(req.session.uid);
const needLogin = (req, res, next) => me(req) ? next() : res.redirect('/login');
const needAdmin = (req, res, next) => me(req) && me(req).admin ? next() : res.status(403).send(page('Forbidden', '<p>Admins only.</p>'));

app.get('/login', (req, res) => res.send(page('Login', `<h1>Login</h1><form method="post">
<input name="email" placeholder="email" required><input name="password" type="password" placeholder="password" required>
<button>Login</button></form><p>Demo: student@example.com / student123, buyer@example.com / buyer123</p>`)));

app.post('/login', (req, res) => {
  const u = db.findUser(req.body.email);
  if (!u || !bcrypt.compareSync(req.body.password, u.hash)) return res.status(401).send(page('Login', '<p>Wrong email or password. <a href="/login">Try again</a></p>'));
  req.session.regenerate(() => { req.session.uid = u.id; res.redirect('/'); });
});
app.post('/logout', (req, res) => req.session.destroy(() => res.redirect('/login')));

app.get('/', needLogin, (req, res) => {
  const u = me(req);
  const html = db.courses().map(c => {
    const owned = u.paid.includes(c.id);
    const rows = db.videos(c.id).map(v => `<li><img src="https://${process.env.CDN_DOMAIN}/${v.thumb}" width="120" alt="">
      <a href="/watch/${v.id}">${esc(v.title)}</a> ${v.free ? '<b>FREE</b>' : owned ? '' : '🔒'}</li>`).join('');
    return `<h2>${esc(c.title)} ${owned ? '(purchased)' : `- ₹${c.price}`}</h2><ul class="videos">${rows}</ul>`;
  }).join('');
  res.send(page('Courses', `<p>Hi ${esc(u.email)} <form method="post" action="/logout" style="display:inline"><button>Logout</button></form>
  ${u.admin ? '<a href="/admin">Admin</a>' : ''}</p>${html}`));
});

app.get('/watch/:id', needLogin, async (req, res, next) => {
  try {
    const v = db.video(Number(req.params.id)); if (!v) return res.sendStatus(404);
    const u = me(req);
    if (!v.free && !u.paid.includes(v.courseId)) return res.status(403).send(page('Locked', '<p>Buy this course to watch this lesson.</p>'));
    const src = await aws.playUrl(v.key, !v.free); // signed only for paid
    res.send(page(v.title, `<h1>${esc(v.title)}</h1><video controls autoplay width="720" src="${esc(src)}"></video>
      <p class="muted">${v.free ? 'Free preview.' : `Signed link valid for ${process.env.SIGNED_MINUTES || 10} minutes.`}</p>`));
  } catch (e) { next(e); }
});

// ---- admin: upload or replace a video ----
app.get('/admin', needAdmin, (req, res) => res.send(page('Admin', `<h1>Upload video</h1>
<form id="up"><select name="courseId">${db.courses().map(c => `<option value="${c.id}">${esc(c.title)}</option>`).join('')}</select>
<input name="title" placeholder="title" required><label><input type="checkbox" name="free"> free preview</label>
<input name="replaceId" placeholder="replace video id (optional)">
<p>Video <input type="file" name="video" accept="video/mp4" required> Thumbnail <input type="file" name="thumb" accept="image/jpeg" required></p>
<button>Upload</button></form><pre id="log"></pre><script src="/admin.js"></script>`)));

app.post('/admin/upload-url', needAdmin, async (req, res) => {
  const { courseId, free, kind, replaceKey } = req.body;           // kind: video | thumb
  const id = crypto.randomUUID();
  const key = replaceKey || (kind === 'thumb' ? `thumbs/${courseId}/${id}.jpg` : `${free ? 'free' : 'videos'}/${courseId}/${id}.mp4`);
  res.json({ key, url: await aws.uploadUrl(key, kind === 'thumb' ? 'image/jpeg' : 'video/mp4') });
});

app.post('/admin/videos', needAdmin, async (req, res) => {
  const { replaceId, courseId, title, free, key, thumb } = req.body;
  const v = db.upsertVideo({ id: replaceId ? Number(replaceId) : undefined, courseId: Number(courseId), title, free: !!free, key, thumb });
  if (replaceId) await aws.invalidate([`/${key}`, `/${thumb}`]);   // same key replaced -> clear edge copies
  res.json(v);
});

app.use((err, req, res, next) => { console.error(err); res.status(500).send(page('Error', '<p>Something went wrong.</p>')); });
app.listen(process.env.PORT || 3000, () => console.log('course platform on :' + (process.env.PORT || 3000)));

aws.js

// All AWS calls in one place. Credentials come from the EC2 IAM role - no access keys.
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl: s3Presign } = require('@aws-sdk/s3-request-presigner');
const { CloudFrontClient, CreateInvalidationCommand } = require('@aws-sdk/client-cloudfront');
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const { getSignedUrl: cfSign } = require('@aws-sdk/cloudfront-signer');

const REGION = process.env.AWS_REGION || 'ap-south-1';
const s3 = new S3Client({ region: REGION });
const cf = new CloudFrontClient({ region: 'us-east-1' });
const ssm = new SSMClient({ region: REGION });
let privateKey; // cached after first read

async function loadKey() {
  if (!privateKey) {
    const r = await ssm.send(new GetParameterCommand({ Name: process.env.CF_PRIVATE_KEY_PARAM, WithDecryption: true }));
    privateKey = r.Parameter.Value;
  }
  return privateKey;
}

// Free files: plain CloudFront URL. Paid files: signed URL that expires.
async function playUrl(key, signed) {
  const url = `https://${process.env.CDN_DOMAIN}/${key}`;
  if (!signed) return url;
  const minutes = Number(process.env.SIGNED_MINUTES || 10);
  return cfSign({
    url,
    keyPairId: process.env.CF_KEY_PAIR_ID,
    privateKey: await loadKey(),
    dateLessThan: new Date(Date.now() + minutes * 60 * 1000).toISOString()
  });
}

// Admin upload: browser PUTs the file straight to S3 with this short-lived URL.
function uploadUrl(key, contentType) {
  return s3Presign(s3, new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, ContentType: contentType }), { expiresIn: 600 });
}

// When a file is replaced under the same key, drop the old copy from every edge.
function invalidate(paths) {
  return cf.send(new CreateInvalidationCommand({
    DistributionId: process.env.DIST_ID,
    InvalidationBatch: { CallerReference: String(Date.now()), Paths: { Quantity: paths.length, Items: paths } }
  }));
}

module.exports = { playUrl, uploadUrl, invalidate };

data.js

// Demo data store. Swap this file for MySQL/RDS in production.
const fs = require('fs');
const bcrypt = require('bcryptjs');
const FILE = __dirname + '/db.json';

const seed = {
  users: [
    { id: 1, email: 'admin@example.com', hash: bcrypt.hashSync('admin123', 10), admin: true,  paid: [1] },
    { id: 2, email: 'student@example.com', hash: bcrypt.hashSync('student123', 10), admin: false, paid: [] },
    { id: 3, email: 'buyer@example.com', hash: bcrypt.hashSync('buyer123', 10), admin: false, paid: [1] }
  ],
  courses: [ { id: 1, title: 'AWS for Beginners', price: 499 } ],
  videos: [
    // free preview: key under free/, paid lessons: key under videos/
    { id: 1, courseId: 1, title: 'Welcome (free preview)', free: true,  key: 'free/1/welcome.mp4', thumb: 'thumbs/1/welcome.jpg' },
    { id: 2, courseId: 1, title: 'Lesson 1: Regions and AZs', free: false, key: 'videos/1/lesson1.mp4', thumb: 'thumbs/1/lesson1.jpg' }
  ]
};

let db = fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE)) : seed;
const save = () => fs.writeFileSync(FILE, JSON.stringify(db, null, 2));
if (!fs.existsSync(FILE)) save();

module.exports = {
  findUser: (email) => db.users.find(u => u.email === email),
  getUser: (id) => db.users.find(u => u.id === id),
  courses: () => db.courses,
  videos: (courseId) => db.videos.filter(v => v.courseId === courseId),
  video: (id) => db.videos.find(v => v.id === id),
  upsertVideo(v) {
    const old = db.videos.find(x => x.id === v.id);
    if (old) Object.assign(old, v); else { v.id = Math.max(0, ...db.videos.map(x => x.id)) + 1; db.videos.push(v); }
    save(); return v;
  }
};

public/admin.js

// Browser side of admin upload: get pre-signed PUT URLs, send files straight to S3, then save metadata.
const f = document.getElementById('up'), log = document.getElementById('log');
const post = (u, b) => fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }).then(r => r.json());
f.onsubmit = async (e) => {
  e.preventDefault();
  const d = new FormData(f), free = d.get('free') === 'on', courseId = d.get('courseId'), replaceId = d.get('replaceId');
  const put = async (file, kind) => {
    const r = await post('/admin/upload-url', { courseId, free, kind });
    log.textContent += `uploading ${kind} -> ${r.key}\n`;
    const s = await fetch(r.url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
    if (!s.ok) throw new Error(kind + ' upload failed ' + s.status);
    return r.key;
  };
  try {
    const key = await put(d.get('video'), 'video'), thumb = await put(d.get('thumb'), 'thumb');
    const v = await post('/admin/videos', { replaceId, courseId, title: d.get('title'), free, key, thumb });
    log.textContent += 'saved video id ' + v.id + '\n';
  } catch (err) { log.textContent += err.message + '\n'; }
};

Flask variant (sign_flask.py)

# Flask variant of the signing endpoint - pip install flask botocore cryptography
import datetime, os
from flask import Flask, abort, session, jsonify
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

app = Flask(__name__); app.secret_key = os.environ["SESSION_SECRET"]
KEY = serialization.load_pem_private_key(open(os.environ["CF_PRIVATE_KEY_PATH"], "rb").read(), password=None)
signer = CloudFrontSigner(os.environ["CF_KEY_PAIR_ID"], lambda m: KEY.sign(m, padding.PKCS1v15(), hashes.SHA1()))
VIDEOS = {1: {"key": "free/1/welcome.mp4", "free": True, "course": 1}, 2: {"key": "videos/1/lesson1.mp4", "free": False, "course": 1}}

@app.get("/api/play/<int:vid>")
def play(vid):
    v = VIDEOS.get(vid) or abort(404)
    url = f"https://{os.environ['CDN_DOMAIN']}/{v['key']}"
    if v["free"]: return jsonify(url=url)
    if v["course"] not in session.get("paid", []): abort(403)
    exp = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=10)
    return jsonify(url=signer.generate_presigned_url(url, date_less_than=exp))

PHP variant (sign.php)

<?php // PHP variant of the signing endpoint - composer require aws/aws-sdk-php
require 'vendor/autoload.php';
session_start();
$videos = [1 => ['key' => 'free/1/welcome.mp4', 'free' => true, 'course' => 1],
           2 => ['key' => 'videos/1/lesson1.mp4', 'free' => false, 'course' => 1]];
$v = $videos[(int)($_GET['id'] ?? 0)] ?? null;
if (!$v) { http_response_code(404); exit; }
$url = 'https://' . getenv('CDN_DOMAIN') . '/' . $v['key'];
if (!$v['free']) {
    if (!in_array($v['course'], $_SESSION['paid'] ?? [])) { http_response_code(403); exit('Buy the course'); }
    $cf = new Aws\CloudFront\CloudFrontClient(['region' => 'us-east-1', 'version' => '2020-05-31']);
    $url = $cf->getSignedUrl(['url' => $url, 'expires' => time() + 600,
        'private_key' => getenv('CF_PRIVATE_KEY_PATH'), 'key_pair_id' => getenv('CF_KEY_PAIR_ID')]);
}
header('Content-Type: application/json');
echo json_encode(['url' => $url]);

bucket-policy.json (OAC)

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontOACRead",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::yourname-course-media/*",
    "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1ABCDEF2GHIJ" } }
  }]
}

cors.json

[{ "AllowedOrigins": ["http://YOUR-EC2-PUBLIC-DNS:3000"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3000 }]

app-role-policy.json (EC2 role)

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": "s3:PutObject",
      "Resource": ["arn:aws:s3:::yourname-course-media/videos/*", "arn:aws:s3:::yourname-course-media/free/*", "arn:aws:s3:::yourname-course-media/thumbs/*"] },
    { "Effect": "Allow", "Action": "cloudfront:CreateInvalidation", "Resource": "arn:aws:cloudfront::111122223333:distribution/E1ABCDEF2GHIJ" },
    { "Effect": "Allow", "Action": "ssm:GetParameter", "Resource": "arn:aws:ssm:ap-south-1:111122223333:parameter/course/cf-private-key" }
  ]
}

Why each permission: PutObject only on the three prefixes (admin uploads), CreateInvalidation only on this distribution (replacing a file), GetParameter only on the key parameter. The role cannot read videos; it does not need to.

4. Build it step by step

How to do it (step by step)

Part A: bucket, keys, distribution

  1. S3 (ap-south-1) › Create bucket yourname-course-media, Block all public access on, ACLs disabled, SSE-S3.
  2. Upload seed files: aws s3 cp welcome.mp4 s3://yourname-course-media/free/1/welcome.mp4; aws s3 cp lesson1.mp4 s3://yourname-course-media/videos/1/lesson1.mp4.
  3. Thumbnails with a 1-year cache: aws s3 cp welcome.jpg s3://yourname-course-media/thumbs/1/welcome.jpg --cache-control "public,max-age=31536000" (same for lesson1.jpg).
  4. Bucket › Permissions › CORS › paste cors.json with your app address. Why: the admin browser PUTs to S3.
  5. Keys: openssl genrsa -out private_key.pem 2048 && openssl rsa -pubout -in private_key.pem -out public_key.pem.
  6. Store the private key: aws ssm put-parameter --region ap-south-1 --name /course/cf-private-key --type SecureString --value file://private_key.pem.
  7. CloudFront › Public keys › Create course-key (paste public_key.pem). Key groups › Create course-kg with it. Note the key ID.
  8. ACM in us-east-1 › request videos.yourdomain.com, DNS validation, create the Route 53 record, wait for Issued.
  9. Create distribution: origin = bucket REST endpoint + new OAC; default behavior: Redirect HTTP to HTTPS, CachingOptimized, Restrict viewer access: Yes, key group course-kg; alternate domain videos.yourdomain.com; the ACM cert; TLSv1.2_2021; Price Class 200; WAF optional.
  10. Apply bucket-policy.json (CloudFront shows it with your ARN filled in).
  11. Behaviors › Create /thumbs/* and /free/*: same origin, CachingOptimized, Restrict viewer access No. Create /videos/*: Restrict viewer access Yes, course-kg.
  12. Route 53 › A and AAAA alias records videos → the distribution.

Part B: the app on EC2

  1. IAM › Create policy from app-role-policy.json (your account, bucket, distribution). Role for EC2 course-app-role with it.
  2. Launch EC2: Amazon Linux 2023, t3.micro, role course-app-role, security group 22 from My IP and 3000 from anywhere (lab).
  3. sudo dnf install -y nodejs22 unzip. Why 22: AWS SDK v3 releases from early 2027 need Node 22+.
  4. Copy the zip, unzip course-video-platform.zip && cd course-video-platform && npm install.
  5. cp .env.example .env; nano .env; set -a; . ./.env; set +a (fill bucket, domain, DIST_ID, key ID).
  6. npm start. Expected: course platform on :3000.

Part C: tests

  1. Open http://EC2-PUBLIC-DNS:3000, log in as student@example.com / student123. Expected: course list with thumbnails from videos.yourdomain.com.
  2. Click the FREE video. Expected: it plays. DevTools › Network: the MP4 URL has no Signature and returns 200 (or 206 for range requests).
  3. Click the 🔒 lesson. Expected: "Buy this course to watch this lesson." (403 from the app).
  4. Try the file without a link: curl -sI https://videos.yourdomain.com/videos/1/lesson1.mp4 | head -1. Expected: HTTP/2 403.
  5. Log out, log in as buyer@example.com / buyer123, open the lesson. Expected: it plays; the URL has Expires, Signature and Key-Pair-Id.
  6. Copy that video URL. curl -sI "URL" | grep -iE "^HTTP|x-cache" twice. Expected: 200, Miss then Hit.
  7. Wait 11 minutes, run it again. Expected: HTTP/2 403. Why: Expires has passed; a shared Telegram link is useless.
  8. Bypass: curl -sI https://yourname-course-media.s3.ap-south-1.amazonaws.com/free/1/welcome.mp4 | head -1. Expected: 403.
  9. Admin: log in as admin@example.com / admin123 › Admin › upload a new MP4 and JPG. Expected log: two "uploading" lines and "saved video id 3". The new video appears in the list.
  10. Replace test: upload again with replace id 3. Then aws cloudfront list-invalidations --distribution-id EXXXX. Expected: a new invalidation. Why: the app clears old copies from the edges.

5. Making it production-ready

AreaChange
DataMove data.js to RDS MySQL (users, courses, videos, purchases tables)
PaymentsA Razorpay or Stripe webhook adds the course to user.paid
HLSConvert MP4 to HLS with MediaConvert and use signed cookies for /videos/42/*
App front doorPut the app behind an ALB + HTTPS; sessions in ElastiCache
SecurityAWS WAF rate limit on /login; change demo passwords; rotate the key yearly

6. Cleanup

  1. Terminate the EC2 instance; delete role course-app-role and its policy.
  2. Route 53: delete the videos A/AAAA records. CloudFront: disable, then delete the distribution.
  3. Delete key group course-kg, public key course-key, the OAC, and the ACM certificate (us-east-1).
  4. aws ssm delete-parameter --name /course/cf-private-key; aws s3 rb s3://yourname-course-media --force; shred -u private_key.pem.

7. Interview questions

Q. How do you stop paid video links from being shared?

Private bucket with OAC, a restricted behavior with a key group, and short-expiry signed URLs (or cookies) issued only after a purchase check.

Q. Why not stream videos through the Node app?

It would carry every byte and need huge bandwidth. CloudFront serves from edge caches; the app only signs links.

Q. Where is the CloudFront private key stored?

In SSM Parameter Store as a SecureString (or Secrets Manager), read through the EC2 role. Never in code.

Q. Do signed URLs break caching?

No. CloudFront strips the signature parameters from the cache key, so all buyers share the cached object.

Q. How does an admin upload a 2 GB video without loading the server?

The app returns an S3 pre-signed PUT URL; the browser uploads straight to S3 (multipart for very large files).

Try at home

  1. Add a purchases table in MySQL and a fake "Buy" button that adds the course for the logged-in user.
  2. Switch paid playback to signed cookies for /videos/1/* and play two lessons without new links.
  3. Run the Flask variant and compare its signed URL with the Node one.

In short

  1. Private S3 + OAC: nobody reaches files directly.
  2. Behaviors: /thumbs/* and /free/* open, /videos/* and default signed.
  3. The app checks payment, then signs a 10-minute URL.
  4. Admin uploads go straight to S3; invalidate when a key is overwritten.
  5. Tests: free plays, locked is 403, expired link is 403, S3 direct is 403.

Practice Tasks

About this scenario

Solapur Chaddar House is a home textiles business in Solapur. Vikas Kamble looks after its IT and cloud work. They count money in INR and run their AWS resources in the ap-south-1 Region. In the tasks below you do real jobs for them using what this lesson teaches. All names, account IDs, IP addresses and numbers are made up for practice.

0 / 10

Easy List distributions

Why: Find the distribution ID and domain name.

What to do: List all CloudFront distributions.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Easy How many addresses in a /16?

Why: Picking a VPC size needs this count.

What to do: Solapur Chaddar House plans the VPC 10.86.0.0/16. How many IP addresses does it hold in total?

How:
  1. Write down the numbers the task gives you.
  2. Work it out step by step on paper.
  3. Type only the final answer in the box.
  4. Click Check answer.

Easy Port for SSH

Why: Security group rules need the right port.

What to do: Which TCP port must the security group open for SSH?

How:
  1. Write down the numbers the task gives you.
  2. Work it out step by step on paper.
  3. Type only the final answer in the box.
  4. Click Check answer.

Medium Clear the CloudFront cache

Why: After a deploy, invalidate so visitors get the new files now, not after the TTL.

What to do: Invalidate all paths (/*) on distribution E2QWRUHAPOMQZL.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Hard Create a distribution from a config file

Why: The full config (origins, behaviors, certificate) is easiest to keep in JSON.

What to do: Create a CloudFront distribution from dist.json.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Hard Invalidate two paths

Why: Invalidating only what changed is cheaper and faster.

What to do: On E2QWRUHAPOMQZL, invalidate /index.html and /css/site.css.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Hard Bucket policy for CloudFront OAC

Why: With OAC, only your CloudFront distribution may read the private bucket.

What to do: Allow the service cloudfront.amazonaws.com to s3:GetObject on solapurchaddar-site/*, only when aws:SourceArn equals arn:aws:cloudfront::115372646265:distribution/E2QWRUHAPOMQZL.

How:
  1. Start with { "Version": "2012-10-17", "Statement": [ ... ] }.
  2. In each statement write Effect, Action and Resource.
  3. Give only what the task needs (least privilege).
  4. Use double quotes and commas correctly. Click Check answer.

Hard Create an Origin Access Control

Why: OAC lets only CloudFront read your private S3 bucket.

What to do: Create an origin access control from oac.json.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Hard Request a free certificate for CloudFront

Why: CloudFront only uses ACM certificates from us-east-1. DNS validation renews automatically.

What to do: Request a certificate for solapurchaddar.in and www.solapurchaddar.in with DNS validation, in us-east-1.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Hard Make a CloudFront signed URL

Why: Signed URLs let only paying users watch a video, for a limited time.

What to do: Sign https://d111.cloudfront.net/course/intro.mp4 with key pair ID K2JCJMDEHXQW5F, private key private_key.pem, expiring 2026-12-31.

How:
  1. Read the task and decide which AWS service it is about.
  2. Write aws, then the service name, then the operation (the action word).
  3. Add each needed flag (--name) followed by its value from the task.
  4. Flags can be in any order. Click Check answer.

Revision, comparison and interview cards

Revise this lesson in a few minutes. Save the cards on your phone or download them as PNG files. The QR code on each card opens this lesson.

Revision card: Course platform design. Private S3 + OAC: no direct access. /thumbs/* and /free/* open; /videos/* signed. App checks the purchase, then signs a 10-minute URL. Video bytes never pass through the app.
Revision card: Course platform design · Download PNG
Revision card: Admin upload with pre-signed PUT. App returns an S3 pre-signed PUT URL. Browser uploads the video straight to S3. Bucket CORS must allow PUT from the app. Same key replaced? Invalidate it.
Revision card: Admin upload with pre-signed PUT · Download PNG
Revision card: Keep the signing key safe. Private key in SSM Parameter Store SecureString. EC2 role allows ssm:GetParameter on that one name. Only the public key goes to CloudFront. Store it once.
Revision card: Keep the signing key safe · Download PNG
Comparison: Free preview (/free/*) vs Paid lesson (/videos/*). Free preview (/free/*): Unrestricted behavior; plain CloudFront URL. Paid lesson (/videos/*): Restricted behavior with a key group.
Comparison: Free preview (/free/*) vs Paid lesson (/videos/*) · Download PNG
Interview card: How do you stop paid video links being shared?. Private bucket with OAC; restricted behavior with a key group. Sign short URLs only after a purchase check. A shared link expires in minutes and returns 403.
Interview card: How do you stop paid video links being shared? · Download PNG

More cards: Ravi Sir's Cards.