← blog

Cloud networking: what stays public, what stays private

Design a cloud network with clear boundaries: public endpoints, private services, storage permissions, and developer access, using a video app as the example.

My upload reaches 100%, but the video still says “Processing.” Its bytes have arrived; something behind the website must turn them into a playable video. Until I publish it, those bytes should stay private—even though anyone can open example.com.

I’m building a small YouTube-like video product on AWS. This is my example architecture, not a description of YouTube’s infrastructure. The familiar actions give every infrastructure decision a job.

BROWSE
Browser -- HTTPS --> public web entry --> private video app
                                             |
                                             +-- SQL --> metadata DB
UPLOAD
Browser -- ask permission --> video app
Browser -- authorized upload -----------------> raw video storage

WATCH
Player -- HTTPS --> CDN -- cache miss --> processed video storage

MAINTAIN
Developer -- authenticated private access --> development tools
CI pipeline -- scoped cloud API calls -------> deploy the app

That is where the design will end up. I start with one server and add the other parts when a real action needs them.

Open the homepage on one server#

I start with one EC2 instance (a virtual server) running the video-sharing app. It serves the homepage at /, the watch page at /watch, and an API (HTTP endpoints called by the frontend) under /api. These can all belong to one application; a separate microservice is not required for each page. Even this first instance needs a VPC and subnet; I define those below as I separate the database.

When I open https://example.com, the browser needs an IP address (a network destination). DNS (the system that looks up names) helps resolve example.com to one. The browser then connects to the destination and sends an HTTP request.

At this first stage, the browser connects directly to the public server on TCP 443. The server returns HTML and browser code; that code requests published video metadata from /api/videos. The important boundary is between calling the application and accessing its storage directly. I keep the packet-level explanation in what happens when I open a website. The expandable exchange follows the initial homepage request.

Expand the DNS lookup, TCP setup, and TLS handshake

I’ll follow a fresh HTTPS connection using TCP (reliable, ordered delivery) and TLS 1.3 (encryption and server authentication). The three-way handshake belongs to TCP: SYN, SYN-ACK, ACK. These messages agree on starting sequence numbers; they do not encrypt anything. TLS then makes its own exchange over that TCP connection. TCP connection establishment

Here I assume no cached DNS answer, no existing connection, and a full TLS 1.3 handshake with a server certificate. Resumption, early data, a retry, and client certificates would change the exchange. TCP acknowledgments during TLS, compatibility messages, and connection closing are omitted. HTTP/3 uses QUIC over UDP instead of this TCP setup.

DNS, TCP three-way handshake, full TLS 1.3 handshake, then encrypted HTTPBrowserclient on my laptopDNS resolvername lookup onlyPublic serverTCP port 4431 · DNS finds the destinationQuery: example.comAssume no usable cached answerAnswer: server IPThe HTTPS request does not go through DNS2 · TCP establishes the connection · three messagesSYNClient proposes its starting sequence numberSYN + ACKServer proposes its own number and acknowledges the clientACKClient acknowledges the server; TCP is established3 · TLS 1.3 establishes encryption and authenticates the serverClientHello + key shareSupported choices and public key-exchange materialServerHello + key shareChosen parameters; both sides can derive handshake keysEncryptedExtensionsEncrypted with handshake keysCertificateEncrypted: the server certificate chainCertificateVerifyEncrypted: signature proving possession of the private keyFinishedEncrypted: confirms the handshake transcript and keysBrowser validates the chain, hostname, signature, and Finished.If a required check fails, stop; do not send the page request.FinishedEncrypted: client confirms its side of the handshake4 · HTTP uses the encrypted connectionGET /Inside TLS application data200 OK + homepage HTMLInside TLS application data
Read top to bottom. Colors and numbered bands separate the jobs; an arrow is a protocol message, not necessarily a separate TCP packet. Scroll sideways on a narrow screen.

The hello messages exchange public key material; the private keys are not sent. Messages after ServerHello are encrypted. The certificate identifies the server, CertificateVerify proves control of its private key, and Finished checks the handshake. HTTP then travels with application traffic keys. This diagram shows the ordinary server-authenticated flow, not a TLS guarantee that every handshake has exactly these messages. TLS 1.3 message flow

DNS helps find the destination. It does not carry the page request or grant permission to enter.

An IP address gets us to a host; a port (a numbered destination for a service) helps its operating system deliver traffic to the right program. Think of a building address and a particular office inside it.

Destination portWhat might be listening thereWho needs it in this example?
TCP 443HTTPS websiteAnyone visiting example.com
TCP 22SSH, an encrypted remote shellAuthorized operators, if enabled
TCP 5432PostgreSQL databaseVideo app and approved developers
TCP 8443Our internal HTTPS serviceThe public entry point

These are conventions and choices, not guarantees. A program can listen on a different port. Moving Postgres to an unusual number doesn’t make it private.

A video page needs metadata, not database access#

I choose EC2 here so the process, network interface, and listening port stay visible. If I already package the backend as a container, ECS on Fargate (managed container execution) can run it without my managing the host operating system. I still choose task subnets, security groups, roles, and load-balancer integration. “Managed” changes my operational work; it does not erase the network decisions. Fargate networking

The app stores channels, video titles, ownership, and publication status in PostgreSQL, a relational database. GET /api/videos/42 returns the information needed to draw a watch page. Updating the title requires the uploader’s permission.

These are small structured records, separate from the video bytes. The backend queries the database and returns HTTP data; the browser never needs the database password.

I could initially run both programs on one machine, with Postgres listening only locally. One server, a public IP, HTTPS on 443, and a firewall rejecting other inbound connections can be a valid beginning.

Publicly reachable does not mean every port is open. And accepting an HTTPS connection does not mean accepting every request inside it.

The problem grows when that same machine serves watch pages, stores video metadata, holds database credentials, and accepts remote administration. One successful break-in can reach too much. Updating the machine can also take everything offline.

I move the database to its own managed resource, then put a public entry in front of the application. These are different callers and different interfaces:

flowchart TB
    U[Anyone on the internet]
    subgraph public[Public entry]
      E[HTTPS entry point]
    end
    subgraph private[Private work]
      S[Video app]
      D[(Metadata database)]
    end
    U -->|HTTPS 443| E
    E -->|HTTPS 8443| S
    S -->|Postgres 5432| D
    U -.->|5432 blocked| D
    linkStyle 0,1,2 stroke:#2f9e68,stroke-width:2px
    linkStyle 3 stroke:#d64545,stroke-width:2px
    classDef entry fill:#3178c618,stroke:#3178c6,stroke-width:2px
    classDef work fill:#2f9e6818,stroke:#2f9e68,stroke-width:2px
    classDef data fill:#9263c618,stroke:#9263c6,stroke-width:2px
    classDef rejected fill:#d6454518,stroke:#d64545,stroke-width:2px
    class U,E entry
    class S work
    class D data
    style public fill:transparent,stroke:#777e88,stroke-width:1.5px
    style private fill:transparent,stroke:#777e88,stroke-width:1.5px

Blue boxes mark the public side, green the application, and purple the data. Green arrows are allowed paths; red dashed arrows are blocked attempts. The browser gets video metadata without a database connection.

This is least privilege (giving each caller only the access its job needs). The separation reduces the damage one compromised component can cause. It doesn’t make that component impossible to compromise.

Give the private machines their own address space#

The app must reach Postgres, but neither needs a directly reachable internet address once the public entry forwards requests. I need an address space for that private communication.

A VPC, or virtual private cloud (an isolated virtual network) gives me a network whose addresses and routes I control. Azure calls its corresponding network a VNet. This is software-defined networking on the provider’s hardware, not a private rack of machines I own. An AWS VPC belongs to a Region, a geographical area where I deploy resources. AWS’s VPC model

I choose 10.20.0.0/16 for this example. CIDR (a compact way to write an address range) uses the number after the slash to say how many leading bits are fixed.

IPv4 address: 32 bits

10.20.0.0/16
└─ 16 fixed bits ─┘  16 bits available to vary

Range: 10.20.0.0 through 10.20.255.255
Size:  2^16 = 65,536 addresses

A smaller slice:
10.20.21.0/24 → 10.20.21.0 through 10.20.21.255
                         2^8 = 256 addresses

These are address counts, not usable server counts; AWS reserves some addresses in each subnet. 10.0.0.0/8 is private address space: the public internet doesn’t route it to my VPC. Another company can use the same addresses inside its own network. If we later connect those networks, overlapping ranges become a problem. AWS subnet addressing

A private address isn’t a password. A machine that gains a permitted network path can attempt a connection. I still need rules about who may connect and what they may do.

A subnet is a slice, not a locked room#

A subnet (a smaller address range inside the network) lets me apply routing and subnet-level policy to a group of resources. In AWS, each subnet belongs to one Availability Zone (a separate failure location within a Region). A VPC can span several zones. AWS subnet boundaries

An instance’s network interface (its virtual network attachment) receives an address from its subnet. The subnet is an address-and-policy boundary, not a physical server, application, or permission to use the data.

I divide the addresses by job, and repeat the layout in two zones:

JobZone AZone BWhat lives here?
Public entry10.20.0.0/2410.20.1.0/24Load balancer connections
Private application10.20.10.0/2410.20.11.0/24Video app instances
Private data10.20.20.0/2410.20.21.0/24Database deployment

Why both job and zone? Job separation controls exposure. Zone separation helps survive a failure. Two subnets in the same zone solve only the first problem.

These /24s are an illustrative small application, not a sizing rule. Each has 251 assignable IPv4 addresses after AWS’s five reservations. I budget for peak instances, overlapping old and new deployments, and service network interfaces. Containers need space too: ECS tasks using awsvpc networking each get an interface; EKS (managed Kubernetes) commonly assigns VPC addresses to its pods (running container groups). A busy container tier may need a much larger range. I choose it before deployment: an existing subnet’s CIDR cannot be resized. Subnet sizing, EKS address planning

The load balancer also needs room to grow. AWS requires its ordinary Availability Zone subnets to be at least /27, with at least eight free addresses in each. A small public tier still needs spare capacity. ALB subnet requirements

A subnet named private-database has no special powers. Within a VPC, local routes normally provide paths between its subnets. Putting two machines in different subnets does not by itself stop them talking.

A public subnet needs a route, not a public-sounding name#

A route table (destination ranges and where to send their packets) answers: “For this address, which way?”

An internet gateway (the VPC’s connection to the internet) provides an internet path. A subnet with a direct route to it is called public. For direct IPv4 internet communication, a resource also needs a public IPv4 address or mapping and permissive firewall rules. A route alone does not expose every machine. AWS route tables

Here is my example’s routing plan:

Subnet’s tableDestinationSend to
All three tiers10.20.0.0/16Local VPC routing
Public entry0.0.0.0/0Internet gateway
Private application0.0.0.0/0Outbound gateway, added when needed
Private dataOther destinationsNo default internet route

0.0.0.0/0 matches every IPv4 address. A more specific matching route wins: a request to 10.20.21.15 uses the /16 local route, not the /0 internet route.

Let the front door forward the work#

One application instance can restart or fail. I run copies in two zones and place the public entry in front of them. That entry is a load balancer (an entry point that distributes requests across healthy servers), which keeps the public address separate from the machines doing the work. An AWS Application Load Balancer, or ALB, can route HTTP requests and check whether targets are healthy. ALB behavior

I create a Route 53 alias record (a DNS record that follows an AWS resource) for example.com pointing at the ALB, rather than copying its current IP addresses. I obtain a certificate for example.com through ACM (AWS Certificate Manager), validate domain control, attach it to the ALB, and configure its listener, the rule for handling connections on port 443. Its target group (the set of backends eligible to receive requests) contains our private app instances on 8443. Route 53 routing to a load balancer, HTTPS listener certificates

flowchart TB
    B[Browser]
    subgraph V[Production VPC · 10.20.0.0/16]
      subgraph P[Public subnets · Zones A and B]
        L[One logical ALB across both zones]
      end
      subgraph A[Private application subnets]
        SA[App · Zone A]
        SB[App · Zone B]
      end
      subgraph D[Private data subnets]
        DB[(Postgres primary)]
        ST[(Standby in other zone)]
      end
      L -->|HTTPS 8443| SA
      L -->|HTTPS 8443| SB
      SA -->|Postgres 5432| DB
      SB -->|Postgres 5432| DB
      DB -->|Replication| ST
    end
    B -->|HTTPS 443| L
    linkStyle 0,1,2,3,4,5 stroke:#2f9e68,stroke-width:2px
    classDef entry fill:#3178c618,stroke:#3178c6,stroke-width:2px
    classDef work fill:#2f9e6818,stroke:#2f9e68,stroke-width:2px
    classDef data fill:#9263c618,stroke:#9263c6,stroke-width:2px
    classDef rejected fill:#d6454518,stroke:#d64545,stroke-width:2px
    class B,L entry
    class SA,SB work
    class DB,ST data
    style V fill:transparent,stroke:#777e88,stroke-width:1.5px
    style P fill:transparent,stroke:#777e88,stroke-width:1.5px
    style A fill:transparent,stroke:#777e88,stroke-width:1.5px
    style D fill:transparent,stroke:#777e88,stroke-width:1.5px

The same colors mark public entry, application work, and data. This is a logical request map; the internet gateway and route tables provide its paths. The standby is for database failover, not a second writable database.

The ALB is not the webpage service: the video app still serves /, /watch, and /api. The ALB forwards requests to a healthy copy. The browser-to-ALB connection ends at the ALB. The ALB makes a separate connection to a backend using its private address. The video app makes another to Postgres. One browser action can produce several distinct network connections.

HTTPS on the backend leg encrypts it, but ALB does not validate the target’s certificate. This is not the browser’s hostname-and-certificate check repeated at the next hop. ALB target TLS

That proxy hop also changes the apparent caller: the backend sees the ALB’s connection. For the original client address, I configure the app to interpret X-Forwarded-For only through trusted proxies, not trust arbitrary values supplied by callers. Separate connections also have separate idle clocks: ALB defaults to 60 seconds; a backend closing earlier can cause a 502 race. I keep the backend’s idle timeout longer than the ALB’s. Forwarded headers, ALB idle timeout

For the database I choose Amazon RDS, managed relational database hosting, with public accessibility disabled. The database subnet group covers both zones; a Multi-AZ deployment supplies failover. Merely listing two subnets doesn’t enable database replication. Apps use the database’s DNS endpoint because its address can change during failover. RDS in a VPC

Failover still needs the client to cooperate. A connection pool keeps database connections open for reuse. I discard broken pooled connections and let new connections resolve the endpoint again. For Java, AWS recommends a DNS cache lifetime of no more than 60 seconds; caching forever can leave the app trying the old address. RDS failover and DNS

Two zones are my choice for this web tier and primary/standby database, not a rule for every distributed service. In a three-voter system split two-and-one, losing the zone with two voters removes the majority. Its failure placement needs a different design. Two zones also aren’t regional disaster recovery: I still need backups, tested restores, and enough surviving capacity.

A route says where. A firewall says whether.#

Now I can reach the right machines internally. I don’t want every internal machine to reach every service.

A security group (a stateful network firewall attached to supported resources’ network interfaces) filters connections by source or destination, protocol, and port. Stateful means it remembers allowed connections and permits their response traffic. It doesn’t understand whether an HTTP request is reading video metadata or deleting it. AWS security groups

I attach one group to the ALB, one to the app instances, and one to the RDS database. These are desired inbound rules for the production path; each row assumes no other attached group grants broader access:

GroupAllow inbound fromDestination portMeaning
entry-sg0.0.0.0/0TCP 443Anyone can reach the web entry
app-sgentry-sgTCP 8443Only the entry’s interfaces can reach the app
database-sgapp-sgTCP 5432Only the app can start database connections

The corresponding outbound policy allows entry to app on 8443 and app to database on 5432. Health checks use the permitted backend port here. Other dependencies need deliberate rules of their own.

The process must actually listen on the interface the ALB reaches. Binding the backend only to 127.0.0.1 makes it local to that machine. Listening on 0.0.0.0:8443 with TLS accepts connections on its IPv4 interfaces; it does not assign a public IP or bypass the security group. The route, firewall, and running program all have to agree.

ALB -- HTTPS 8443 --> app interface -- accepted socket --> backend
                         |                                  |
                    app-sg allows?                    listening here?

A running instance is not necessarily a ready application. I configure an ALB health check such as /health/ready on the backend port, allow its traffic, and monitor target health. Health checks guide routing; they are not an access-control boundary. If all registered targets are unhealthy, ALB can route to all of them rather than block all requests. ALB health checks

Referencing app-sg avoids tracking every changing application IP. It matches the relevant network interfaces associated with that group; it doesn’t authenticate a Java process or inherit the group’s rules. An attacker controlling an allowed application server has that network access too. Security group references

Security groups have allow rules, not explicit deny rules. Multiple attached groups contribute their allowed traffic together: a broad rule in one can undo the narrow intent of another. “Deny unless needed” requires checking the whole set.

Why an ACL needs a rule for the reply#

A network ACL (access control list) is another packet filter, applied at a subnet boundary. Unlike security groups, AWS network ACLs are stateless (they evaluate packets without remembering the connection). Rules have numbers; the first matching rule wins. They can explicitly allow or deny. They don’t filter communication between machines within the same subnet. AWS network ACLs

Suppose the app opens a database connection from temporary port 51000:

Database subnet ACL checks outbound replies independently of inbound requestsVideo app10.20.10.8DB subnet boundaryACL checks packetsPostgres10.20.21.151 · Request · destination port 5432Source 51000 → destination 5432Crossing into the DB subnetAllow inbound TCP 5432From the permitted application range2 · Reply · destination port 51000Source 5432 → destination 51000Crossing out of the DB subnetAllow outbound TCP 51000Covered by the client ephemeral-port ruleIf that outbound rule is missingThe reply is dropped at the DB subnet boundary.Allowing inbound 5432 does not automatically allow its reply.
The ACL is a filter at a boundary, not another server. Blue and green distinguish request and reply; red shows the missing-rule failure. The app subnet must also permit both directions.

Port 5432 belongs to the database listener. Port 51000 belongs to this client’s connection. Replies reverse the source and destination.

A security group recognizes this as response traffic. A restrictive ACL needs matching return-path rules; an outbound rule allowing only destination 5432 would not allow this reply. Real rules cover the appropriate ephemeral port range (temporary ports chosen by clients), not just 51000. Both subnets’ ACLs matter. AWS ACL examples

I use security groups for precise service relationships. An ACL can add a subnet-wide guardrail, but duplicating every rule there adds another place to break the return path.

Connection questionSecurity groupNetwork ACL
Where does it apply?Supported resource interfacesTraffic entering or leaving a subnet
Can it deny explicitly?No; allow rules combineYes; lowest-numbered match wins
Does a permitted request permit replies?Yes, through connection trackingNo; check the reverse direction separately
Can it separate two instances in one subnet?Yes, with suitable group rulesNo
What do I use it for here?App-to-database permissionAdditional subnet-wide restrictions

Every subnet has an associated NACL. I can leave the default allow-all NACL while enforcing workload access with security groups; a new custom NACL starts by denying traffic. Tightening it is an extra responsibility, not a replacement for the resource rules. NACL defaults and evaluation

A working connection does not grant access to an unpublished video#

Alice owns an unpublished video. Bob changing the URL to /api/videos/42 must not reveal its private metadata or issue him a playback grant.

A packet filter sees addresses, protocols, and ports. It cannot decide whether the caller may view an unpublished video.

CheckQuestion it answersWhat it cannot establish
IP routing, network layerIs there a path to this destination?Whether I should use it
SG / ACL, network and transport fieldsMay this connection pass?Whether this video is public
TLSIs this connection encrypted to the checked server?Permission to view a private video
Application authenticationWhich user is calling?Permission to every user’s data
Application authorizationMay this user view this video?Whether another service is safely configured
Database grantsMay this DB role run this SQL operation?The end user’s identity unless the app carries it through
Cloud IAMMay this principal perform this cloud API action?An automatic route or ordinary database login

IAM (identity and access management) controls actions such as changing infrastructure or reading cloud storage. Permission to modify an RDS instance isn’t automatically permission to query its tables. IAM database authentication is a separate, explicitly configured feature, and database privileges still matter. AWS IAM

A valid connection can carry a forbidden private-video requestBrowserVideo appVideo policyGET unpublished videoCheck Bob’s accessAccess denied403: no video metadata
The connection succeeds. The application refuses the operation because Bob lacks access.

A reachable service is not permission to use every operation it exposes. The network is doing its job even when the application refuses this request.

The app needs its own identity too#

Alice’s session identifies a person to our app. An IAM role (permissions a workload can use with temporary credentials) identifies the app to AWS. These are different identities: the cloud role does not tell Postgres which videos Alice owns.

I store the metadata database password in Secrets Manager. The app instance or task gets an IAM role allowed to read that specific secret; unrelated secrets are outside its grant. Retrieving the password still needs a network path to Secrets Manager, through an appropriate private endpoint or outbound internet path. The database then applies its own login and SQL permissions. Secret access policies

The app retrieves only its own credential before connecting to PostgresVideo appSecrets ManagerMetadata PostgresGetSecretValue: metadata secretCredential, if IAM permitsTLS + database authentication
Cloud permission releases the secret. It does not replace database authentication or grant SQL privileges.

Uploading a video should not occupy the web server#

A video might be gigabytes; its title is a few words. Passing every upload through the app ties up its connections and bandwidth. I use S3 object storage (files stored under keys in buckets) for the bytes, with separate raw-upload and processed-video buckets. The database holds each video’s owner, status, and object references.

The browser first calls POST /api/uploads. The app authenticates the creator, checks the upload allowance, allocates a new video ID and object key, and returns a presigned URL (a temporary grant for a specific S3 operation). The browser uploads directly to S3 using that URL.

The app grants a scoped upload; the bytes go directly to S3BrowserVideo appS3 raw bucketPOST /api/uploadsScoped upload URLPUT video bytes with URLUpload accepted
The ALB hop is omitted. The app signs the upload grant with its role credentials; it does not send a request to S3 merely to generate the URL.

The URL limits the operation and object key; it does not give the browser the app’s secret credentials or permission to list the bucket. It is still a bearer credential: anyone holding it can use the grant while it is valid, potentially more than once. A URL signed with an instance or task role’s temporary credentials expires when those credentials expire, even if I requested a longer lifetime. I use short expirations and unique keys, and verify the completed object before processing it. S3 presigned uploads

For large files I use multipart upload (separately uploading pieces of one object) so interrupted pieces can be retried. The backend authorizes the upload session and appropriate part requests; abandoned uploads need cleanup. Browser uploads also need S3 CORS rules (which browser origins may make cross-origin requests). CORS is not authentication and does not stop a non-browser client. Multipart uploads, S3 CORS

I keep Block Public Access enabled. A public S3 API endpoint can accept our authorized upload while refusing anonymous reads. Publicly reachable is different from publicly readable. IAM and bucket policies control object actions; our application decides which creator may request a grant. S3 access controls

S3 is an AWS service; my bucket is a resource created in it. The bucket uses IAM and storage policies, not a security group. I keep object ACLs disabled with Bucket owner enforced ownership; an object ACL is a storage grant, not a network ACL. S3 Object Ownership

An upload is not yet a playable video#

Different viewers need different formats and bitrates. Transcoding (making new encoded versions of a video) is CPU-heavy work that should not run inside the upload request. I add a background worker (a program processing jobs outside the browser request) and SQS (a managed message queue) to hold work until a worker takes it.

After verifying upload completion, the app submits the video ID and immutable input version. The worker reads the raw video, writes playable versions and thumbnails, then reports completion through an authenticated internal app operation. The app validates the job ID and input version before accepting completion; a worker identity alone is not permission to mark any video complete. Only the app updates metadata. The video remains “processing” until the required outputs exist.

The app submits processing work and a worker polls for itVideo appSQS queueVideo workerSendMessage: video versionJob acceptedReceiveMessageProcessing job
The worker initiates polling, so the queue needs no inbound connection to it. It acknowledges the job only after successful processing and completion reporting.
Worker -- GetObject --> raw bucket
Worker -- encode --> output files
Worker -- PutObject --> processed bucket
Worker -- report completion --> internal video API -- SQL --> metadata
Worker -- DeleteMessage --> queue

Standard SQS can deliver a job more than once. Processing the same input version must be idempotent (safe to repeat): use versioned output keys and prevent duplicate completion from corrupting metadata. The database update and queue submission also need recovery; I record pending work durably and retry submission if interrupted. A queue does not make two separate writes atomic. SQS delivery behavior

Long encodes need suitable visibility timeouts (how long a received job is hidden from other workers), extended while work continues. Failed jobs get bounded retries and a dead-letter queue (jobs set aside for investigation). I monitor the oldest waiting job, not merely whether a worker is running. SQS visibility

I could operate these workers on EC2 or containers. MediaConvert is the managed encoding alternative: submit jobs and grant its service role access to the selected input and output locations. Its processing fleet is AWS-managed; I would not draw it as my own EC2 workers in my subnet. MediaConvert, AWS video-on-demand design

The encoder should not inherit the web app’s access#

Segmentation (separating parts and controlling their communication) gives the public entry, application, and database different rules. Microsegmentation makes finer distinctions between workloads, even in the same private tier. These are architectural patterns, not AWS product names: subnets and NACLs can separate broad groups; security groups or workload policies can restrict individual callers. Neither pattern belongs exclusively to one control.

Video app [app-sg] -- SQL 5432 ------------------> metadata DB
Video worker [worker-sg] -- SQL 5432 ------------> BLOCKED
Video worker -- authenticated completion API ---> video app
Video worker -- scoped object operations -------> video buckets
WorkloadPermitted pathSeparate permission required
Web appMetadata DB on 5432App SQL role; no schema-administration grant
Video workerS3 and SQS over HTTPSRead raw inputs, write restricted outputs, consume processing jobs
Worker → internal appHTTPS 8443 from worker-sgWorker identity may report completion only
Worker → metadata DBNo DB SG allow ruleNo database credential issued

I add an inbound app-sg rule for worker-sg on 8443, and matching worker egress. The database still accepts only app-sg. The public ALB listener rejects /internal/* before its ordinary forwarding rule; the app also authenticates and authorizes internal callers. An internal-looking URL is not protection.

The worker needs no inbound rule for its queue polling; stateful rules permit replies to allowed outbound connections. Workloads sharing one interface and SG are not distinguished by process name. I need separate workload interfaces or additional enforcement to isolate them. A compromised encoder still has its granted file access, so blanket bucket permissions would defeat the intended separation.

A private endpoint changes the path to S3#

The bucket does not sit inside our database subnet. An S3 gateway endpoint (a route-table target for reaching S3 privately) gives the app a path to the regional S3 service without an internet or NAT gateway. Its policy restricts that path; it does not grant access missing from the relevant identity and resource policies.

Worker -- signed S3 request --> subnet route table
                                |
                          S3 prefix-list route
                                |
                          gateway endpoint -- GetObject --> S3
                                                           |
                                                   permission checks

A prefix list (a named set of IP ranges) lets the route identify S3 destinations. The endpoint does not need a security group. By contrast, an interface endpoint puts private network interfaces in selected subnets; its security group must allow the caller, usually on TCP 443. I use that model for private access to Secrets Manager or the processing queue later. S3 gateway endpoints, Interface endpoints

Worker access can use this private path. Browser uploads cannot: a bucket-wide rule requiring our VPC endpoint would reject uploads from home, including presigned requests. I scope policies for the intended callers instead of assuming every request must originate inside the VPC.

Pressing Play takes a different path from opening the page#

The watch page needs video metadata, but the player needs a stream of bytes. I keep those paths separate. The app returns the title, publication status, and a playback location; a CDN (content delivery network, which caches content near viewers) serves the video.

I use CloudFront at media.example.com, with the processed S3 bucket as its origin (the source it fetches on a cache miss). The homepage and API remain at example.com behind the ALB. CloudFront is another public entry with a different job, not a component inside our application subnet.

Playback cache miss and subsequent cached deliveryVideo playerCloudFrontProcessed S3GET video segmentCache miss: signed GETSegment bytesSegment responseGET same cached segmentCache hit: segment response
A later eligible cache hit avoids an origin request. The application’s metadata database does not serve the video bytes.

An adaptive player reads a manifest (a list of available versions and media segments) and requests short pieces at a suitable bitrate. The worker prepares those versions; the CDN delivers them. A cache miss still costs origin work, and popular content can create a burst before caches are warm. At an illustrative 4 Mb/s each, 1,000 simultaneous viewers need about 4 Gb/s of delivery; ten minutes is roughly 300 GB of payload. Caching reduces repeated origin reads, not the bytes delivered to viewers.

For a delivery-only estimate, suppose roughly 300 GB is billable in CloudFront’s US/Mexico/Canada pay-as-you-go tier at $0.085/GB: about $25.50. This assumes the monthly 1 TB free transfer allowance is already used and usage remains in the next 9 TB tier. Requests, storage, encoding, and other costs are extra; other locations and pricing plans differ. Rates checked September 2026. CloudFront pricing

I configure Origin Access Control, OAC (CloudFront authentication to the S3 origin) to always sign requests, and a bucket policy allowing reads from the intended distribution. Anonymous direct S3 reads remain blocked. The S3 bucket stays private even when a published video is watchable by everyone through the CDN. CloudFront origin access

DecisionPublic videoUnpublished or restricted video
App metadata APIReturn published metadataCheck viewer entitlement
CDN viewer accessPublic playback path may accept anyoneRequire a signed URL or signed cookie on the restricted path
S3 origin accessOnly intended delivery identitiesSame origin restriction; OAC alone does not authorize viewers

The app issues private playback grants only after checking permission. CloudFront verifies the grant before serving restricted content, including cached content. I keep separate public and restricted cache behaviors and protect both manifests and segments. Newly processed outputs stay on the restricted path; only an authorized publish operation makes a version available through the public path. An S3 upload URL and a CloudFront playback URL are different grants, signed and checked by different mechanisms. CloudFront private content, Signed cookies for multiple video files

Changing a public video to private also needs a delivery plan: previously public cached objects and already-issued grants do not disappear just because a database flag changed. I first close access through the old public path, then invalidate stale public copies and expire grants as appropriate. Versioned paths help avoid reusing old public URLs; downloaded copies cannot be recalled. Nor would I cache personalized API responses as public video files.

For media.example.com, I configure the CloudFront hostname and HTTPS certificate separately from the ALB. CloudFront’s ACM viewer certificate must be in us-east-1; the ALB certificate belongs in the ALB’s Region. CloudFront certificate requirements

The viewer-to-product request is north–south traffic (crossing the product’s external boundary). Calls between internal components are east–west traffic. An app-to-database request is outbound at the app and inbound at the database; “outbound” does not necessarily mean “to the internet.”

The app must sometimes call out#

File storage and job queues can use private endpoints. A third-party integration or package download may still need an internet destination. That outbound connection does not require opening an inbound administration port.

The application’s NAT gateway (a service that translates connection addresses) lets it initiate IPv4 connections outward, perhaps to fetch an update. The zonal public NAT gateway shown below translates the app’s source to its own private address; the internet gateway maps that to the NAT gateway’s public address. Replies follow the reverse mappings. It doesn’t accept unsolicited internet connections into the private server. AWS NAT behavior

Outgoing IPv4 connection and its replies through public NAT and the internet gatewayPrivate appPublic NATInternet gatewayPackage server1 · Outbound connection · source address changesTo package : 443Source: app private IPForward packetSource: NAT private IPForward packetSource: NAT public IP2 · Reply · destination translations lead back to the appReply to public IPEstablished flowTo NAT private IPPublic mapping reversedTo app private IPNAT connection mapping
Blue follows the outbound packet; green follows its reply. These devices forward packets, not terminate TLS. Unsolicited traffic has no published inbound service on this NAT path.

The application starts this conversation. Public page requests take a different path through the load balancer; they do not come in through NAT.

One zonal NAT shared by both zones makes their outbound access depend on that zone. For independent egress, each private subnet can route through a NAT in its own zone. AWS also offers a regional NAT gateway: in automatic mode it expands with workloads across zones, without a public subnet to host it. Expansion can take up to 60 minutes, during which traffic may cross zones. The diagram above shows the zonal model. Regional NAT behavior

NAT also has a meter running when traffic is quiet. Using AWS’s published US East (Ohio) example rates, checked September 2026:

CostWorked example
Two zonal gateways, 730 hours2 × 730 × $0.045 = $65.70
100 GB processed through them100 × $0.045 = $4.50
Subtotal$70.20, before public IPv4 and applicable data transfer charges

Regional NAT is also billed per active zone-hour, not once for the entire Region. An S3 gateway endpoint can keep S3 traffic off this NAT path without endpoint hourly or processing fees. That saving does not apply to every kind of private endpoint. VPC pricing

An idle TCP mapping can disappear after 350 seconds; reuse then receives a reset. A pool of reusable connections therefore needs an idle policy too. NAT timeouts

NAT isn’t a web-content filter. An infected app with broad outbound access can also send stolen data outward. I would narrow outbound access and use private service endpoints where suitable. IPv6 needs its own routes and rules; an IPv4-only policy doesn’t cover it.

My laptop needs a different entrance#

The public watch page now works. But my database client on my laptop times out. The database has a private address, and my home network has no usable path into this VPC.

Connecting a VPN (an encrypted tunnel into another network) can add that path. It doesn’t turn the database public or change its password.

I use a separate development account and VPC, 10.30.0.0/16, with test data. Production remains 10.20.0.0/16. Developers build and test their changes in development; production access is a separate, narrower grant. The public product path does not pass through their VPN.

VPN access followed by database exchangesLaptopVPN endpointPrivate PostgresAuthenticate; establish tunnelTCP + database TLS through tunnelDatabase login, then SQL and result
Each row groups several messages. The VPN forwards the database traffic; TLS and the database login still run between the laptop and Postgres.

AWS Client VPN needs a target network association, routes, and authorization for the destination network. A connected tunnel alone isn’t permission to every address. With split tunneling (sending only selected destinations through the VPN), ordinary internet browsing can keep using the home connection. Client VPN networking, destination authorization

For the IPv4 VPC-subnet association model here, Client VPN translates client source addresses. I allow the VPN association’s security group into the development database on 5432, rather than assuming Postgres sees my home’s public IP or the original VPN client IP. Other attachment models differ. AWS’s explanation of Client VPN resource access

AWS Client VPN also charges while provisioned. Its published Ohio example is $0.10 for one endpoint associated with one subnet for an hour, plus $0.05 per active client-hour. Ten clients for that hour cost $0.60 before applicable IPv4 and transfer charges; this is not the cost of an always-on month. Client VPN pricing

Private DNS must work too: the laptop needs a resolver path that can resolve the database name. Sometimes the name already resolves without the VPN, but its private address is unreachable. Knowing an address and having a route to it are different things.

The model below uses the development database at 10.30.21.15 and a developer with read-only SQL access. It is a separate copy of the product’s data, not the production database. Start by connecting the VPN, then compare SELECT (read rows) with DELETE (remove rows). The same connection and login can allow one operation and refuse the other.

Same laptop. Same database. Different permission.

Connect the VPN first. Then try reading and deleting with the same read-only database user.

What should this user try?
SELECT title FROM videos WHERE id = 42;

SELECT was not sent: the VPN is off. Database permissions have not been tested yet.

My laptophome networkPRIVATE NETWORKPostgres10.30.21.15 : 5432No private route×
BLOCKED
1. Private route

No VPN route to 10.30.21.15 from this laptop.

NOT REACHED
2. TCP 5432

An earlier check stopped this attempt.

NOT REACHED
3. Database login

An earlier check stopped this attempt.

NOT REACHED
4. SQL permission

An earlier check stopped this attempt.

A teaching model, not a live connection. DNS, the return route, and subnet ACLs are assumed correct. VPN connected includes successful VPN authentication and authorization.

A network path, a valid login, and permission to change data are three different things.

If a database works only after I connect to my work VPN, this is a plausible explanation—not proof of the exact configuration. I would inspect the resolved address, laptop routes, VPN authorization, and firewall rules to find which boundary changed.

One company does not mean one shared network#

Two products can share a parent company without sharing a network or the same operating team.

For my hypothetical video company, I choose network boundaries by ownership and risk:

SituationDesign I would considerReason
App and encoder for this video productOne VPC, distinct SGs and rolesSeparate access without a network per feature
Development versus productionSeparate accounts and VPCsDevelopment credentials should not control production
Independently owned second productSeparate workload accounts and VPCsIndependent policies, changes, and resource limits
Shared serviceExplicit authenticated API and approved connectivityShare the capability without granting blanket network access

An AWS account is a boundary for resource ownership and cloud permissions. A VPC is a regional network boundary. They are not interchangeable; one account can contain multiple VPCs, and shared-VPC designs also exist. AWS recommends separating workloads and production/non-production accounts according to security and operational needs. Separation adds governance and connectivity work. AWS account design

A reporting team needs one private connection#

Later, a reporting job runs in a separate account and VPC, 10.40.0.0/16. It needs an approved viewing summary, not database administration. I expose an authenticated internal report operation on the video app. On the public ALB listener, a higher-priority rule returns a fixed 403 for /internal/* before the ordinary forwarding rule. The app separately checks the reporting identity, and the reporting client verifies the app’s TLS certificate. A path name alone is not protection. ALB fixed responses

VPC peering (a private network connection between two VPCs) can provide this path. It does not merge accounts, automatically install routes, or grant SQL permissions.

flowchart TB
    subgraph r[Reporting VPC · 10.40.0.0/16]
      R[Report job · 10.40.10.9]
    end
    P[Peering connection]
    subgraph v[Product VPC · 10.20.0.0/16]
      A[Private app · 10.20.10.8]
      D[(Metadata database)]
    end
    R -->|HTTPS 8443| P
    P -->|Forward packet| A
    A -->|Scoped SQL| D
    R -.->|SQL not allowed| D
    linkStyle 0,1,2 stroke:#2f9e68,stroke-width:2px
    linkStyle 3 stroke:#d64545,stroke-width:2px
    classDef entry fill:#3178c618,stroke:#3178c6,stroke-width:2px
    classDef work fill:#2f9e6818,stroke:#2f9e68,stroke-width:2px
    classDef data fill:#9263c618,stroke:#9263c6,stroke-width:2px
    classDef rejected fill:#d6454518,stroke:#d64545,stroke-width:2px
    class R,A work
    class P entry
    class D data
    style r fill:transparent,stroke:#777e88,stroke-width:1.5px
    style v fill:transparent,stroke:#777e88,stroke-width:1.5px
Peering carries the app request; it does not terminate HTTPS or authorize the report. The job receives only the summary returned by the app.

For that one connection trace, I need both directions:

Where I configure itDestination / permissionWhy
Reporting subnet route table10.20.10.0/24 → peeringReach the app subnet
App subnet route table10.40.10.0/24 → peeringReturn traffic to the reporting subnet
Reporting SG outboundApp destination, TCP 8443Permit the connection to start
App SG inboundReporting caller, TCP 8443Admit that caller
Custom NACLs, if restrictiveRequest and ephemeral return portsLet both packet directions cross subnet boundaries
Internal API authorizationReporting identity may fetch summariesPrevent arbitrary app operations

Peering routes, Peer security groups

The two VPCs must have non-overlapping ranges. This example is in one Region, where peer security-group references are supported. Additional app subnets need corresponding routes; replacing an app instance also requires a stable private name and working service discovery. Private DNS needs its own configuration—for example, associating an internal Route 53 private hosted zone with both VPCs. Peering alone does not share every private name. Peering setup, Private hosted zones

Peering is not transitive (connections do not automatically chain):

Development -- peer link --> Product -- peer link --> Reporting
Development -- no transit through Product ---------> Reporting

Those two links do not create a development-to-reporting route through the product VPC. At a larger network count, a Transit Gateway (a routing hub for connected networks) can reduce the web of pairwise connections. Its routing still needs deliberate isolation; the hub is not permission for everyone to reach everything. Peering limitations, Transit Gateway

Who notices when a database rule changes?#

The video platform now spans several accounts. I want the same rule everywhere: production databases accept connections only from approved application and maintenance callers. Someone opens a database security group to a wider range during debugging and forgets to undo it. AWS enforces the new rule faithfully—even though it violates our intended design.

The control plane (the systems that configure infrastructure) needs to detect that difference. The data plane (the systems handling live traffic) continues applying the installed rules. A successful policy edit is not proof that every network already enforces it.

I would first consider AWS Firewall Manager (central management of supported security policies). With AWS Organizations, it can apply policies to selected accounts and resources, including new ones entering scope. Remediation settings determine whether supported violations are automatically corrected. Terraform can define infrastructure; ongoing detection and correction still need an owner and a running process. Firewall Manager policies, Policy scope

The underlying idea is reconciliation (repeatedly comparing what should exist with what actually exists). If I need custom policy automation, its cycle looks like this; the version numbers are my example, not an AWS API contract.

Desired policy v42: approved callers only on database port 5432
                           |
                     read latest intent
                           v
                 Policy controller
                   |             ^
          apply differences      | inspect installed rules
                   v             |
                Account / Region APIs
                           |
                    configure filters
                           v
                 Installed SG rules
                           |
                   allow or reject TCP
                           v
                   Database interface

The controller changes rules through management APIs. Database connections pass through the installed filters; they do not ask the controller for permission on every packet.

Suppose ten accounts need v42. Five are verified, two requests time out, one is throttled, one target was deleted, and one account is still pending. An eleventh account joins during the rollout.

What happenedWhat the controller must do
Five verifiedRecord the observed configuration and version; keep checking for later changes
Two timeoutsRead back before retrying: the write may have succeeded despite the missing reply
One throttledRetry with backoff and jitter, with an alert if it remains unresolved
One target deletedRefresh inventory; distinguish intentional removal from missing required infrastructure
One pending, one new accountDiscover both and apply the policy if they are in scope
An old v41 job wakes upDiscard stale work; serialize writes per target and verify the latest intent afterward

Retries must be safe to repeat. A version field alone cannot stop an old request already in flight from reaching AWS; the writer must control ordering and recheck actual state. The dashboard should say “5 verified, others unresolved,” not “rollout complete.”

Convergence takes time and depends on permissions, reachable APIs, and retryable failures being resolved. An overly broad rule can remain active during that gap. I would test a policy on a small scope before expanding it, restrict who can change production rules, and alert on drift (configuration that no longer matches the intended rules). Automation can also spread a mistaken policy faster.

Shipping code doesn’t require a public admin port#

Developers need to change the application, but public users don’t need its deployment interface.

I prefer a deployment pipeline that builds a versioned artifact and replaces running instances. CI/CD (automated build, test, and deployment) uses a narrowly scoped cloud role, with short-lived credentials, to request the change.

With OIDC federation (using the CI provider’s signed identity to obtain cloud credentials), the job exchanges a token with AWS STS (the service that issues temporary credentials). The role’s trust policy restricts the provider, intended audience, and repository plus approved branch or deployment environment. Its permission policy separately limits what the job may deploy. A token from just any repository must not qualify. AWS’s GitHub OIDC trust guidance

CI exchanges workload identity for temporary deployment credentialsCI jobAWS STSDeployment APIOIDC token + requested roleTemporary role credentialsDeploy versioned artifactDeployment status
STS issues credentials only when the role trusts this job. The deployment API changes the service; the job does not open a public shell on its servers.

This shows control flow; artifact downloads need their own permitted network path. The developer doesn’t need an inbound shell connection to every application server.

For exceptional shell access to EC2, Systems Manager Session Manager can use an agent’s outbound service connection, with IAM authorization, instead of public inbound SSH. The agent still needs its role and network access through NAT or suitable VPC endpoints. The VPN remains useful for approved private database tools and internal applications. Session Manager connectivity

Recommendations fail; the video should still play#

I add search over published titles and descriptions when the catalog grows. A private search service maintains an index, updated asynchronously from publication changes. Recommendations are another optional backend dependency. Neither gets a public database port or unrestricted access to account records.

These calls introduce a different boundary: failure isolation. A security group can reject an unauthorized connection. It cannot make an allowed service respond promptly or stop its callers from exhausting their own threads.

UNBOUNDED DEPENDENCY
Watch-page request --> recommendations stalls
                   --> request slots fill
                   --> more retries --> more load
                   --> watch pages fail too

ISOLATED DEPENDENCY
Watch-page request --> recommendations deadline expires
                   --> omit recommendations
                   --> return the playable page
FailureDesign response
Recommendations is slowGive it a short deadline within the page budget; omit that optional section
Repeated dependency failuresCircuit breaker: temporarily stop calls, then probe recovery
Retried requests amplify loadRetry only suitable operations, with a limit, backoff, and jitter (randomized delay)
Encoders consume all capacitySeparate worker capacity and bounded concurrency from the web tier
Uploads outpace processingMonitor queue age, limit admissions, and scale within downstream capacity
Search index lags or failsKeep direct video playback independent; show unavailable search rather than invented results

Separate connection/thread pools—often called bulkheads (resource partitions that limit spillover)—keep one dependency from using every request slot. Queues absorb temporary bursts, not unlimited work. For example, if arrivals stay at 20 videos/minute and workers finish 12, backlog grows by 8 every minute until something changes. AWS resilience strategies, Circuit breakers

A fallback is safe only for optional work. If authorization for a private video fails, I refuse playback; I do not treat the outage as permission. Network isolation, authorization, and failure containment solve different problems.

More app servers can overwhelm one database#

A connection pool (open database connections kept for reuse) saves repeated setup. But each app instance owns its own pool. Adding instances multiplies their possible database connections.

Suppose I budget 80 connections for the application, after reserving room for operators and other clients. This is an example budget, not an RDS default. Six instances with a pool cap of 10 can use 60 connections. Twelve can demand 120—even if twelve exist only while old and new versions overlap during deployment.

Doubling instances can exceed the database connection budgetPool cap per instance: 10Application budget: 806 instances × 10 = 60 possible connections12 instances × 10 = 120 possible connections40 over budgetPlan for peak instance count, including deployment overlap.
Bar lengths show possible simultaneous connections, not request throughput. The red section exceeds this example’s 80-connection application budget.

I size pool caps against peak instance count and the database’s actual connection limit, then put a time limit on waiting for a free connection. RDS Proxy can share database connections across application clients and queue or reject excess demand. It adds cost and does not give the database unlimited query capacity. RDS Proxy connection pooling

Names I keep separate: services, resources, ACLs, traffic, and gateways

Service versus resource

My video app is a program I build. AWS uses “service” for a capability it operates, such as EC2 or S3. A resource is a particular instance of something I create or manage using that capability.

AWS serviceResource in this productWhat controls its access?
EC2, virtual serversAn app instance and its network interfaceSecurity groups for traffic; IAM for cloud actions
Elastic Load BalancingOur Application Load BalancerALB security group, listeners, and routing rules
RDS, managed databasesThe metadata PostgreSQL instanceSecurity groups plus database authentication and grants
S3, object storageRaw and processed video bucketsIAM and bucket policies, Block Public Access; no bucket security group
Secrets ManagerThe app’s database secretIAM and applicable resource policy; no security group on the secret

“I use AWS” does not tell me which boundary protects a resource. Security groups attach where the service supports them; they are not a universal wrapper around AWS resources.

Network ACL versus object ACL

ACL means access control list. The rest of the name tells me what is being controlled.

ControlExample decisionLooks at
Network ACLLet a packet enter this subnetAddress, protocol, port, direction
S3 bucket/object ACLGrant an AWS account access to this bucket/objectStorage grants, not network ports
S3 bucket policyAllow or deny selected S3 actions under conditionsIdentities, actions, resources, conditions

For this bucket I use Bucket owner enforced ownership, the default for new buckets, which disables ACLs. I use policies instead. There is no object ACL to configure as a “second NACL.” S3 Object Ownership

Traffic direction depends on the boundary

Direction depends on the boundary I name. It is not an intrinsic label attached to an IP address.

TermBoundary being describedExample here
North–southOutside ↔ inside the product systemBrowser requests the web API; player requests a CDN segment
East–westCommunication among internal componentsWorker reports completion to the app; app queries metadata
Inbound / ingressEntering the resource under discussionA SQL request entering the DB interface
Outbound / egressLeaving that resourceThe same SQL request leaving the app interface
Internet egressAn internal workload calls an internet destinationBackend calls an external email provider
App interface                   Database interface
   outbound -- TCP 5432 request --> inbound
   inbound  <-- response ---------- outbound

That SQL call is east–west overall, outbound at the app, and inbound at the database. Outbound does not necessarily mean internet traffic. Response packets reverse direction; a stateful SG recognizes them, while a NACL checks them separately.

The browser-to-S3 upload crosses the product’s external boundary even though it bypasses our VPC. Product boundaries and VPC boundaries are not identical.

Gateway names describe different jobs

ComponentJob hereAttach a security group to it?
Internet gatewayInternet routing for the public tierNo
NAT gatewayTranslate outbound connections from private workloadsNo; filter at the workloads and relevant subnet boundaries
S3 gateway endpointRoute S3 traffic without NATNo; endpoint and S3 permissions still apply
Interface endpointPrivate interface for a supported service APIYes, on its endpoint interfaces
ALBAccept HTTP requests and make backend connectionsYes

NAT gateway filtering limits

A gateway is not one universal firewall. Its type tells me which path it provides.

How the network roles map to Azure

The concepts survive changing providers. The implementations aren’t exact synonyms.

Networking jobAWS example used hereAzure counterpart
Isolated address spaceVPCVirtual Network, VNet
Address subdivisionSubnet, tied to one zoneSubnet, not tied to one zone
Stateful connection filteringSecurity groupNetwork Security Group, NSG
Regional HTTP entryApplication Load BalancerApplication Gateway
Developer tunnelClient VPNVPN Gateway point-to-site
Explicit outbound translationNAT GatewayNAT Gateway
Private access to a managed serviceVPC endpoint / PrivateLink, depending on servicePrivate Endpoint / Private Link
Permissions on cloud actionsIAM roles and policiesAzure RBAC; managed identities for workloads

Azure NSGs can attach to subnets or network interfaces, have ordered allow and deny rules, and remain stateful. An NSG on a subnet is therefore not the same behavior as an AWS stateless subnet ACL. Azure NSGs, Azure network concepts

Azure’s managed web platforms also distinguish private inbound access from outbound VNet integration. I would choose those connections by direction, not assume “connected to the VNet” solves both. Azure’s private web application architecture

Turning this drawing into the first deployment#

The infrastructure is another versioned part of the product. I describe resources and their references with infrastructure as code (reviewable files that create and update cloud resources). CloudFormation or Terraform can express this dependency order; they do not choose safe permissions for me.

Build in this orderConcrete output for the first release
NetworkVPC, six subnets, route tables, internet gateway, and the required outbound paths
Boundaries and identitiesEntry/app/database SGs, app role, and separate deployment role
Persistent stateRDS subnet group, private database, app database user, and secret
ApplicationVersioned build, private instances, DB hostname, secret reference, and TLS listener on 8443
Public entryALB, target group, readiness check, ACM certificate, and HTTPS listener
NameRoute 53 alias for example.com pointing at the ALB

This table builds the web/API foundation. Upload and playback add raw/processed buckets, scoped upload grants, processing jobs and workers, then a CloudFront distribution with origin and viewer policies. Search, recommendations, and a reporting network are later additions, not prerequisites for the first playable video.

For each application copy, I keep durable files out of its local disk and avoid login sessions that only that copy understands. A request can land on another healthy instance. The release runs database migrations with a separate, limited migration identity; ordinary app credentials do not need permission to change the schema. Old and new app versions must both tolerate the schema during a rolling replacement.

Build artifact -- deploy --> new app instances
                                 |
                            readiness checks
                                 |
                       admit into target group
                                 |
                   drain and retire old instances

Before relying on the architecture, I exercise both its allowed and refused paths:

AttemptExpected resultIf it fails unexpectedly, inspect
Home browser opens /Homepage loadsDNS, certificate, ALB listener and target health
Viewer requests another creator’s unpublished videoApplication refusesLogin and video visibility checks
Home laptop connects to production PostgresNo direct connectionPublic accessibility, routes, and SG exposure
Developer VPN connects to the development DBApproved SQL worksPrivate DNS, VPN authorization, routes, SGs, NACLs, DB login
Same developer sends DELETE with read-only roleSQL permission deniedDatabase grants, not the VPN
Encoder opens a metadata DB connectionBlockedCombined SG rules and unintended credentials
Browser uploads with an app-issued grantObject storedGrant, CORS, object key, signer and bucket policies
Anonymous request to a raw S3 objectRefusedBucket and identity policies; Block Public Access
Restricted CDN segment without a valid grantRefused, even if cachedMatching cache behavior and viewer access settings
Recommendations becomes unavailableWatch page still works without recommendationsDeadline, fallback, and separate resource limits
One app copy is replacedOther copy serves requestsCapacity, health checks, shared state, connection draining

A timeout is not proof of one specific bad rule. I trace the destination and route, then packet filters, listener, TLS, and application permission. A clear authorization error means I reached a service capable of refusing the operation; opening more network access is usually the wrong response.

The request we let in can still be the dangerous one#

A database with no public route is harder to attack directly. But the public application is deliberately allowed to reach it. A SQL injection flaw can abuse that permitted path.

flowchart TB
    A[Attacker]
    W[Public web entry]
    B[Vulnerable application]
    D[(Private database)]
    A -.->|Direct SQL blocked| D
    A -->|Malicious HTTPS| W
    W -->|Forward request| B
    B -->|Unsafe SQL| D
    linkStyle 0,1,2,3 stroke:#d64545,stroke-width:2px
    classDef entry fill:#3178c618,stroke:#3178c6,stroke-width:2px
    classDef work fill:#2f9e6818,stroke:#2f9e68,stroke-width:2px
    classDef data fill:#9263c618,stroke:#9263c6,stroke-width:2px
    classDef rejected fill:#d6454518,stroke:#d64545,stroke-width:2px
    class A rejected
    class W entry
    class B work
    class D data

Here red traces the attack: the dashed shortcut is blocked, but the solid path reaches the database through the vulnerable app. Safe SQL construction and narrow database grants must protect that allowed network path too.

A DDoS attack (many sources overwhelming a service) can exhaust bandwidth or connection capacity before application code can help. Edge capacity and AWS Shield Standard address common network and transport attacks. HTTP floods can also exhaust expensive application work, so request filtering and bounded work still matter. AWS Shield protections

A WAF (web application firewall) inspects HTTP fields such as paths, query strings, headers, and supported body content at a protected entry such as CloudFront or ALB. Configured rules can block matching attack patterns; rate-based rules can curb abusive request rates. WAF inspection has limits and cannot establish that Bob owns Alice’s video. AWS WAF request inspection

Parameterized SQL (passing values separately from SQL instructions) keeps supplied values from becoming executable SQL syntax. It belongs in the application’s database access; WAF is an additional defense. Ownership checks and narrow database grants remain necessary even with safe queries.

Attempt against the video platformThe boundary that addresses itWhat that boundary cannot promise
Flood the public endpointEdge/DDoS protection; WAF for HTTP patternsUnlimited capacity or zero cost
Connect directly to database port 5432Private routing and SG/NACL filteringSafety of SQL sent by an allowed app
Make the encoder reach unrelated servicesWorkload isolation and scoped IAMSafety of everything the encoder is allowed to read
Read someone else’s unpublished videoApplication ownership checks and restricted playback grantsProtection if a valid bearer grant is stolen
Use a stolen deployment credentialNarrow role permissions and short-lived credentialsNo damage while those credentials remain usable
Open databases across accounts by mistakeReviewed policies, staged rollout, and reconciliationInstant correction or a correct policy by itself

When an attempt fails, I need evidence from the layer that made the decision:

My questionEvidence to collect
Who changed the database security group?CloudTrail management events: cloud caller, API action, time, and result
Was TCP traffic accepted or rejected at the interface?VPC Flow Logs: addresses, ports, and ACCEPT/REJECT records
Which HTTP request reached the entry, and what happened?ALB access logs for request/target results; WAF logs for rule actions
Did Bob read or publish Alice’s video?Application audit logs: authenticated user, video, action, and authorization decision

These logs need deliberate collection, retention, and access controls. Flow Logs do not contain SQL or HTTP bodies, and ACCEPT does not mean the application succeeded. CloudTrail records cloud API activity; it cannot supply the video ownership decision our application made. I would correlate timestamps and request IDs where available, without recording credentials or signed URLs. CloudTrail management events, Flow Logs, ALB access logs, WAF log fields

Backups need restore tests; secrets need rotation; dependencies need updates.

The price of separation is more routing, policies, monitoring, and failure cases. Load balancers, VPNs, NAT, private endpoints, and redundant databases can carry costs even at low traffic. A small product may sensibly start with fewer moving parts. The rule I keep is to expose only the interface that has a reason to be public.

If an attacker takes over an encoder, which videos can it read—and what else can it reach?

Comments

Signed in with GitHub. Be kind.