The most cost effective means of providing a low cost web experience without sacrificing flexibility, control, scale or performance is to take the server out of equation. No server(s) to manage no costs. Instead replacing the server with web experience hosted in the cloud on a CDN. Instead of using a relational database use other alternative lower-cost data storage cloud solutions like AWS s3, AWS App Sync, Open Search, DynamoDB, Athena, etc. All of these services living in the cloud can be communicated with directly in the browser using zero-trust signed urls. This eliminates the need to create, manage, and instrument code separate from the one running the web experience. The web experience itself ui is build using JavaScript. Highly recommended using a powerful modern web app framework like Angular to build those ui experiences. There are others like React and Vue but I would highly recommend Angular due to many of reasons mentioned in the below article.
https://sam-redmond.com/why-i-always-use-angular-5efcf31d739a
Specifically now to your question I would create a Angular application that uploads files directly to s3. That application would be hosted on s3 as well in a private bucket. The app would be served through amazons CDN cloud front. An app like that in free tier would probably run for a long time for free and even as it scales would be very low cost like dollars a month at most.
The reason I recommend aws is not only because of my familiarity with it but also because the signed v4 request architecture compatible with ALL AWS services can truly be leveraged to communicate securely with AWS services in the browser alone. This is the key to removing the server completely from the app dev equation and significantly reducing costs all around from infrastructure to personel.
Building these types of applications quickly that can run on near 0 cost cloud architecture is a primary objective of the project I have been developing.
The most excessive cost in my current stack is Open Search Service which can also be reduced using AWS Athena instead for my purposes of dynamic route strategy for mapping app routes to components for rendering.
My app currently uploads images to s3. However, it does through an older rest api I built in go. That will soon be replaced by a in browser zero-trust call to s3 api. Eliminating the need for the separate api and moving more towards a pure server-less application.
This entire architecture can run any application at significantly lower cost not only in terms of infrastrcuture but personel as well. When compared to a legacy php app like wordpress. Especially if that pile of grabage needs to scale. Scaling a php application requires adding more nodes and possibly for cpu. All that comes at a cost. That can be completely eliminated using rhe approach above I described taking the server out of the equation. Running a static application from a cdn cost nothing in comparison to running a wordpress website which requires a variable amount of costly infrastrcture based on traffic needs.
Anyway to sum up
- Deliver web experience as static site hosted on CDN
- Persist data to cloud based pay for usage modeled storages not MySQL
- Eliminate servers by securely communicating with services in cloud within browser alone.
Added bonus
- Pick storages that are highly available, scalable, low cost , performant out of the box NOT MySQL
developer happiness
- Store data as natural form in JSON ideally NOT across a complex hierarchy of relational tables
- Allow developers to focus on experiences not building repetitive rest APIs and building/managing infrastrcture.
Those parties interested this is TypeScript code for a data adaptor part of my project that communicates with AWS s3 securely directly in the browser alone. No middle man, no extra server node /cpu needed nothing. Just direct to s3 using zero-trust singed v4 urls in the browser alone. This one does’t upload upload binary documents it uloads data entities as json documents. This replaces something like MySQL were the entity would be spread across a sewer of tables.
import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-provider-cognito-identity';
import { AuthFacade } from 'auth';
import { CognitoSettings } from 'awcog';
import { CrudAdaptorPlugin, CrudOperationResponse, CrudOperationInput, CrudCollectionOperationInput, CrudCollectionOperationResponse } from 'crud';
import { Param, ParamEvaluatorService } from 'dparam';
import { AllConditions, AnyConditions, ConditionProperties } from 'json-rules-engine';
import { firstValueFrom, forkJoin, from, iif, Observable, of } from 'rxjs';
import { catchError, map, switchMap, take, tap } from 'rxjs/operators';
import * as uuid from 'uuid';
import { SignatureV4} from "@aws-sdk/signature-v4";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { Sha256 } from "@aws-crypto/sha256-js";
import { HttpClient } from '@angular/common/http';
import { isPlatformServer } from '@angular/common';
import { AsyncApiCallHelperService } from 'utils';
export const s3EntityCrudAdaptorPluginFactory = (platformId: Object, authFacade: AuthFacade, cognitoSettings: CognitoSettings, paramsEvaluatorService: ParamEvaluatorService, http: HttpClient, asyncApiCallHelperSvc: AsyncApiCallHelperService, hostName?: string, protocol?: string) => {
return new CrudAdaptorPlugin<string>({
id: 'aws_s3_entity',
title: 'AWS S3 Entity',
create: ({ }: CrudOperationInput) => of<CrudOperationResponse>({ success: false }),
read: ({ }: CrudOperationInput) => of<CrudOperationResponse>({ success: false }),
update: ({ object, identity, params }: CrudOperationInput) => of({ success: false }).pipe(
map(() => buildClient(authFacade, cognitoSettings)),
switchMap(s3 => identity({ object }).pipe(
map(({ identity }) => ({ s3, identity }))
)),
switchMap(({ s3, identity }) => params && Object.keys(params).length !== 0 ? forkJoin(Object.keys(params).map(name => paramsEvaluatorService.paramValue(params[name], new Map<string, any>()).pipe(map(v => ({ [name]: v }))))).pipe(
map(groups => groups.reduce((p, c) => ({ ...p, ...c }), {})), // default options go here instead of empty object.
map(options => ({ s3, identity, options }))
): of({ s3, identity, options: {} })),
map(({ s3, identity, options }) => {
const name = options.prefix + identity + '.json';
const command = new PutObjectCommand({
Bucket: options.bucket,
Key: name,
Body: JSON.stringify(object),
ContentType: 'application/json',
CacheControl: `ETag: ${uuid.v4()}` // cache could be part of adaptor options - for now KISS
});
return { s3, command };
}),
switchMap(({ s3, command }) => new Observable<CrudOperationResponse>(obs => {
s3.send(command).then(res => {
console.log('sent');
console.log(res);
obs.next({ success: true });
obs.complete();
}).catch(e => {
console.log('error')
console.log(e);
obs.next({ success: false })
obs.complete();
});
}))
),
delete: ({ }: CrudOperationInput) => of<CrudOperationResponse>({ success: false }),
query: ({ rule, params }: CrudCollectionOperationInput) => of({ entities: [], success: false }).pipe(
// This can be moved into crud adaptor call to shared method inside dparams probably.
switchMap(() => iif(
() => Object.keys(params).length > 1,
forkJoin(
Object.keys(params).map(name => paramsEvaluatorService.paramValue(params[name], new Map<string, any>()).pipe(
map(v => ({ [name]: v }))
))
).pipe(
map(groups => groups.reduce((p, c) => ({ ...p, ...c }), {})), // default options go here instead of empty object.
map(options => ({ options }))
),
iif(
() => Object.keys(params).length !== 0,
paramsEvaluatorService.paramValue(Object.keys(params).length !== 0 ? params[Object.keys(params)[0]] : new Param(), new Map<string, any>()).pipe(
map(optionValue => ({ options: { [Object.keys(params)[0]]: optionValue } }))
),
of({ options: {} })
)
)),
// This can be moved into crud adaptor and passed as argument.
map(({ options }) => ({ options, identityCondition: (rule.conditions as AllConditions).all.map(c => (c as AnyConditions).any.find(c2 => (c2 as ConditionProperties).fact === 'identity')).find(c => !!c) })),
switchMap(({ identityCondition, options }) => iif(
() => identityCondition !== undefined && (identityCondition as ConditionProperties).fact === 'identity',
// This could probably be moved into an aw util module to easily build rest queries for any aw service efficently.
createS3SignedHttpRequest({
method: "GET",
body: undefined,
headers: {
"Content-Type": "application/json",
host: `${(options as any).bucket}.s3.amazonaws.com`,
},
hostname: `${(options as any).bucket}.s3.amazonaws.com`,
path: `${(options as any).prefix}${(identityCondition as ConditionProperties).value}.json`,
protocol: 'https:',
service: "s3",
cognitoSettings: cognitoSettings,
authFacade: authFacade
}).pipe(
tap(() => console.log('.marker({ event: AFTER , entity: s3 , op: query , meta: { action: createSignedRequest } })')),
tap(signedHttpRequest => delete signedHttpRequest.headers.host),
map(signedHttpRequest => ({ signedHttpRequest, options, url: `${ isPlatformServer(platformId) ? /*'http://localhost:4000'*/ `${protocol}://${hostName}` /*`https://${options.bucket}.s3.amazonaws.com`*/ : '' }${ `/awproxy/s3/${(options as any).bucket}` }${signedHttpRequest.path}` })),
tap(() => console.log('.marker({ event: BEFORE , entity: crud , op: query , meta: { http: get } })')),
switchMap(({ signedHttpRequest, url }) => http.get(url, { headers: signedHttpRequest.headers, withCredentials: true })),
tap(() => console.log('.marker({ event: AFTER , entity: s3 , op: query , meta: { http: get } })')),
map(res => ({ entities: res ? [ res ] : [], success: res ? true : false }))
),
of({ entities: [], success: false })
))
)
});
};
// This will no longer be needed once write op sdk usage is replaced by signed url
const buildClient = (authFacade: AuthFacade, cognitoSettings: CognitoSettings) => new S3Client({
region: cognitoSettings.region,
credentials: fromCognitoIdentityPool({
client: new CognitoIdentityClient({ region: cognitoSettings.region }),
identityPoolId: cognitoSettings.identityPoolId,
logins: {
[`cognito-idp.${cognitoSettings.region}.amazonaws.com/${cognitoSettings.userPoolId}`]: () => firstValueFrom(authFacade.getUser$.pipe(map(u => u ? u.id_token : undefined)))
}
}),
});
// This should all be moved in either aw util module or awsig module.
// Could also decouple signature from vendor for reuse accross vendors that signed v4 urls can be used.
interface CreateSignHttpRequestParams {
body?: string;
headers?: Record<string, string>;
hostname: string;
method?: string;
path?: string;
port?: number;
protocol?: string;
query?: Record<string, string>;
service: string;
cognitoSettings: CognitoSettings,
authFacade: AuthFacade
}
const createS3SignedHttpRequest = ({
body,
headers,
hostname,
method = "GET",
path = "/",
port = 443,
protocol = "https:",
query,
service,
cognitoSettings,
authFacade
}: CreateSignHttpRequestParams): Observable<HttpRequest> => of(
new HttpRequest({
body,
headers,
hostname,
method,
path,
port,
protocol,
query,
}
)).pipe(
tap(() => console.log('.marker({ event: BEGIN , context: s3, entity: sig , op: signv4 , meta: { } })')),
switchMap(req => from(
(new SignatureV4(
{
credentials: fromCognitoIdentityPool({
client: new CognitoIdentityClient({ region: cognitoSettings.region }),
identityPoolId: cognitoSettings.identityPoolId,
logins: {
[`cognito-idp.${cognitoSettings.region}.amazonaws.com/${cognitoSettings.userPoolId}`]: () => firstValueFrom(authFacade.getUser$.pipe(map(u => u ? u.id_token : undefined)))
}
}),
region: cognitoSettings.region,
service,
sha256: Sha256,
}
)).sign(req)
.then(
signedReq => {
console.log('.marker({ event: RESOLVED, entity: s3 , op: signv4 , meta: { } })');
return signedReq;
}
)
).pipe(
tap(() => console.log('.marker({ /s3/sign/after/sig })')),
take(1)
)),
map(req => req as HttpRequest),
tap(() => console.log('.marker({ event: END , context: s3, entity: sig , op: signv4 , meta: { } })')),
);