├── .gitignore ├── README.md ├── app ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx └── routes │ └── index.tsx ├── package.json ├── patches ├── @web-std+blob+3.0.1.patch ├── @web-std+file+3.0.0.patch └── @web-std+stream+1.0.0.patch ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── server └── index.ts ├── serverless.yml └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /server/build 5 | /public/static 6 | 7 | .serverless 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | - [Serverless Docs](https://www.serverless.com/framework/docs) 5 | 6 | This package is heavily inspired by Remix's Architect starter (`arc` deployment option in `npx create-remix`). 7 | 8 | 9 | ## Serverless Setup 10 | 11 | When deploying to AWS Lambda with the Serverless framework, you'll need: 12 | 13 | - Serverless CLI (`sls` or `serverless`) 14 | - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) 15 | 16 | Serverless recommends installing the serverless cli globally: 17 | 18 | ```sh 19 | npm i -g serverless 20 | ``` 21 | 22 | ## Development 23 | 24 | During development, we will use the standard `remix dev` command. 25 | 26 | ```sh 27 | npm run dev 28 | ``` 29 | 30 | If you want to use `serverless-offline` with some aspects of your project, you'll need to run that as a separate script, or use something like [concurrently](https://npm.im/concurrently) to run both processes in one command. 31 | 32 | Open up [http://localhost:3000](http://localhost:3000) and you should be ready to go! 33 | 34 | ## Deploying 35 | 36 | Before you can deploy, you'll need to [install the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html). 37 | 38 | If you want to use the Serverless Dashboard, you'll need to [configure the Serverless CLI](https://www.serverless.com/framework/docs/getting-started/) and add the `app` key to your `serverless.yml` file. 39 | 40 | If you make it through all of that, you're ready to deploy! 41 | 42 | 1. build the app for production: 43 | 44 | ```sh 45 | npm run build 46 | ``` 47 | 48 | 2. Deploy with `sls` 49 | 50 | ```sh 51 | sls deploy 52 | ``` 53 | 54 | You're in business! 55 | 56 | There's a `deploy` script in `package.json` already that will handle both parts if that's your thing. 57 | 58 | ```sh 59 | # stage defaults to "dev" 60 | npm run deploy 61 | # or 62 | npm run deploy -- --stage production 63 | # or 64 | yarn deploy --stage production 65 | ``` 66 | 67 | The first deployment will take a little while longer because it has to set up the CloudFront distribution, so expect it to take about 5 minutes. 68 | 69 | After deploying, if you want to visit your CloudFront endpoint, you can run `sls info --verbose` to get the `WebsiteDomain` output from the Cloudformation stack. 70 | 71 | ## The Result 72 | 73 | After deploying, you'll have an S3 bucket for storing static assets, your Remix app running in AWS Lambda behind API Gateway, and CloudFront as a CDN in front of both services. 74 | 75 | ## Limitations and First-deployment configuration 76 | 77 | There is a limitation when using CloudFront with API Gateway that CloudFront cannot forward the `Host` header. This means that there isn't a default way to know which 78 | domain a user is requesting from by default. This makes `request.url` in Remix show the API Gateway domain instead of the CloudFront domain, which will be noticeable 79 | when using redirects from actions and loaders or building URLs from the request URL. In order to address this,you need to add the `X-Forwarded-Host` to the origin cache 80 | policy for API Gateway. The `@remix-run/architect` adapter will read this header when transforming the API Gateway request for the Remix request handler. 81 | 82 | ### Overview 83 | 84 | 1. Deploy the stack 85 | 2. run `sls info --verbose` to get the `WebsiteDomain` from "Stack Outputs" 86 | 2. Copy the WebsiteDomain into `serverless.yml` under `custom > dev > HOST` and uncomment the `OriginCustomHeaders` block for the `RemixOrigin`. 87 | 3. Deploy again 88 | 89 | ### Deploy the stack for the first time 90 | 91 | Follow the [deployment](#Deploying) steps. 92 | 93 | ### Get the `WebsiteDomain` 94 | _If you [use a custom domain](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html) for your CloudFront distribution, you can skip to the next section._ 95 | 96 | After the deployment completes, run the following in your terminal at the project root: 97 | 98 | ```sh 99 | sls info --verbose 100 | ``` 101 | 102 | You'll get an output similar to the following: 103 | 104 | ``` 105 | Serverless: Running "serverless" installed locally (in service node_modules) 106 | Service Information 107 | service: remix-serverless-app 108 | stage: dev 109 | region: us-east-1 110 | stack: remix-serverless-app-dev 111 | resources: 16 112 | api keys: 113 | None 114 | endpoints: 115 | ANY - https://xxxxxxxxxxxxx.execute-api.us-east-1.amazonaws.com/{proxy+} 116 | functions: 117 | remix: remix-serverless-app-dev-remix 118 | layers: 119 | None 120 | 121 | Stack Outputs 122 | WebsiteDomain: xxxxxxxxxxxx.cloudfront.net 123 | WebsiteBucketName: remix-serverless-app-dev-websitebucket-somerandomid 124 | DistributionID: XXXXXXXXXXXX 125 | HttpApiId: xxxxxxxxxxxxx 126 | RemixLambdaFunctionQualifiedArn: arn:aws:lambda:us-east-1:xxx:function:remix-serverless-app-dev-remix:1 127 | ServerlessDeploymentBucketName: remix-serverless-app-dev-serverlessdeploymentbuck-xxxxxxxx 128 | HttpApiUrl: https://xxxxxxxxxxxxx.execute-api.us-east-1.amazonaws.com 129 | ``` 130 | 131 | From this output, copy the `WebsiteDomain` value from the "Stack Outputs" section. 132 | 133 | 134 | ### Add the X-Forwarded-Host header 135 | 136 | In [serverless.yml](serverless.yml#L15), add paste the copied WebsiteDomain value. 137 | 138 | ```yml 139 | custom: 140 | dev: 141 | HOST: xxxxxxxxxxxx.cloudfront.net # Replace this value 142 | ``` 143 | 144 | Then, uncomment the [`OriginCustomHeaders` block](serverless.yml#L148): 145 | 146 | ```yml 147 | OriginCustomHeaders: 148 | - HeaderName: X-Forwarded-Host 149 | HeaderValue: ${self:${self:provider.stage}.HOST} 150 | ``` 151 | 152 | ### Deploy again! 153 | 154 | ```sh 155 | sls deploy 156 | # or npm run deploy 157 | ``` 158 | 159 | After deploying, your Remix app will use the domain from the X-Forwarded-Host header as the domain. 160 | 161 | You'll want to add a domain for the `prod` or any other deployment stages you intend to use as well. If you [configure CloudFront to use a custom domain](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html), you will need to use your custom domain as the value instead of the CloudFront default. 162 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | const markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | LiveReload, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration 8 | } from "remix"; 9 | import type { MetaFunction } from "remix"; 10 | 11 | export const meta: MetaFunction = () => { 12 | return { title: "New Remix App" }; 13 | }; 14 | 15 | export default function App() { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {process.env.NODE_ENV === "development" && } 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Index() { 2 | return ( 3 |
4 |

Welcome to Remix

5 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "Remix starter kit for the Serverless Framework", 5 | "license": "MIT", 6 | "scripts": { 7 | "prebuild": "rimraf ./server/build ./public/static", 8 | "build": "remix build", 9 | "dev": "remix dev", 10 | "postinstall": "remix setup node", 11 | "predeploy": "npm run build", 12 | "deploy": "sls deploy" 13 | }, 14 | "dependencies": { 15 | "@remix-run/architect": "^1.1.1", 16 | "@remix-run/react": "^1.1.1", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2", 19 | "remix": "^1.1.1" 20 | }, 21 | "devDependencies": { 22 | "@remix-run/dev": "^1.1.1", 23 | "@remix-run/serve": "^1.1.1", 24 | "@types/react": "^17.0.24", 25 | "@types/react-dom": "^17.0.9", 26 | "aws-sdk": "^2.1048.0", 27 | "esbuild": "^0.14.9", 28 | "patch-package": "^6.4.7", 29 | "rimraf": "^3.0.2", 30 | "serverless": "^2.70.0", 31 | "serverless-esbuild": "^1.23.2", 32 | "serverless-s3-sync": "^1.17.3", 33 | "typescript": "^4.1.2" 34 | }, 35 | "engines": { 36 | "node": ">=14" 37 | }, 38 | "sideEffects": false 39 | } 40 | -------------------------------------------------------------------------------- /patches/@web-std+blob+3.0.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@web-std/blob/tsconfig.json b/node_modules/@web-std/blob/tsconfig.json 2 | deleted file mode 100644 3 | index 2fc2e4e..0000000 4 | --- a/node_modules/@web-std/blob/tsconfig.json 5 | +++ /dev/null 6 | @@ -1,16 +0,0 @@ 7 | -{ 8 | - "extends": "../../tsconfig.json", 9 | - "compilerOptions": { 10 | - "outDir": "dist", 11 | - "paths": { 12 | - "@web-std/blob": ["packages/blob/src/lib.js"] 13 | - } 14 | - }, 15 | - "include": [ 16 | - "src", 17 | - "test" 18 | - ], 19 | - "references": [ 20 | - { "path": "../fetch"} 21 | - ] 22 | -} 23 | -------------------------------------------------------------------------------- /patches/@web-std+file+3.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@web-std/file/tsconfig.json b/node_modules/@web-std/file/tsconfig.json 2 | deleted file mode 100644 3 | index a9e4037..0000000 4 | --- a/node_modules/@web-std/file/tsconfig.json 5 | +++ /dev/null 6 | @@ -1,21 +0,0 @@ 7 | -{ 8 | - "extends": "../../tsconfig.json", 9 | - "compilerOptions": { 10 | - "outDir": "dist", 11 | - "paths": { 12 | - "@web-std/file": ["packages/file/src/lib.js"] 13 | - } 14 | - }, 15 | - "references": [ 16 | - { 17 | - "path": "../blob", 18 | - }, 19 | - { 20 | - "path": "../fetch" 21 | - } 22 | - ], 23 | - "include": [ 24 | - "src", 25 | - "test" 26 | - ] 27 | -} 28 | -------------------------------------------------------------------------------- /patches/@web-std+stream+1.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@web-std/stream/tsconfig.json b/node_modules/@web-std/stream/tsconfig.json 2 | deleted file mode 100644 3 | index c80f2de..0000000 4 | --- a/node_modules/@web-std/stream/tsconfig.json 5 | +++ /dev/null 6 | @@ -1,12 +0,0 @@ 7 | -{ 8 | - "extends": "../../tsconfig.json", 9 | - "compilerOptions": { 10 | - "emitDeclarationOnly": false, 11 | - "noEmit": true, 12 | - "outDir": "dist" 13 | - }, 14 | - "include": [ 15 | - "src", 16 | - "test", 17 | - ] 18 | -} 19 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shamsup/remix-starter-serverless/1f42c0ab4c16eb8c2df825630a68cc88c3c84e04/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | assetsBuildDirectory: "public/static", 7 | publicPath: "/static/", 8 | serverBuildDirectory: "server/build", 9 | devServerPort: 8002, 10 | }; 11 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "@remix-run/architect"; 2 | import type { ServerBuild } from "remix"; 3 | 4 | const build = require('./build') as ServerBuild; 5 | 6 | exports.handler = createRequestHandler({ 7 | build, 8 | getLoadContext(event) { 9 | // use lambda event to generate a context for loaders 10 | return {}; 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: 'remix-serverless-app' 2 | 3 | frameworkVersion: '2' 4 | 5 | package: 6 | individually: true 7 | 8 | plugins: 9 | - serverless-esbuild 10 | - serverless-s3-sync 11 | 12 | custom: 13 | # stage-specific variables 14 | dev: 15 | HOST: my-cloudfront-domain.cloudfront.net # replace with the WebsiteDomain from stack outputs 16 | prod: 17 | HOST: my-domain.com # replace with your production url 18 | # serverless-esbuild config, bundles your code and allows you to use typescript or es-next features in your handler code 19 | esbuild: 20 | bundle: true 21 | minify: false 22 | sourcemap: true 23 | sourcesContent: false 24 | exclude: ['aws-sdk'] 25 | target: 'node14' 26 | define: 27 | 'require.resolve': null 28 | platform: 'node' 29 | # serverless-s3-sync config, handles uploading static files to S3 30 | s3Sync: 31 | buckets: 32 | - bucketNameKey: WebsiteBucketName 33 | bucketPrefix: website/ 34 | localDir: public 35 | params: 36 | - favicon.png: 37 | CacheControl: 'public, max-age=3600' 38 | - 'static/**/*': 39 | CacheControl: 'public, max-age=31536000, immutable' 40 | 41 | provider: 42 | name: aws 43 | runtime: nodejs14.x 44 | stage: ${opt:stage, 'dev'} 45 | region: ${opt:region, 'us-east-1'} 46 | environment: 47 | AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1' 48 | NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000' 49 | lambdaHashingVersion: '20201221' 50 | 51 | functions: 52 | remix: 53 | handler: server/index.handler 54 | description: My Remix app! 55 | events: 56 | - httpApi: 57 | method: any 58 | path: '/{proxy+}' 59 | resources: 60 | Resources: 61 | # Created by serverless 62 | # HttpApi: 63 | # Type: AWS::ApiGatewayV2::Api 64 | # Properties: 65 | # Name: ${self:provider.stage}-${self:service} 66 | # ProtocolType: HTTP 67 | 68 | # S3 bucket for static assets 69 | WebsiteBucket: 70 | Type: AWS::S3::Bucket 71 | Properties: {} 72 | 73 | # Allow CloudFront to use GetObject in the WebsiteBucket 74 | WebsiteOriginAccessIdentity: 75 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 76 | Properties: 77 | CloudFrontOriginAccessIdentityConfig: 78 | Comment: Origin Access Identity to Access ${self:service} Website Bucket ${self:provider.stage} 79 | WebsiteBucketPolicy: 80 | Type: AWS::S3::BucketPolicy 81 | Properties: 82 | Bucket: !Ref WebsiteBucket 83 | PolicyDocument: 84 | Statement: 85 | - Effect: Allow 86 | Action: 87 | - s3:GetObject 88 | Resource: 89 | Fn::Join: 90 | - / 91 | - - Fn::GetAtt: 92 | - WebsiteBucket 93 | - Arn 94 | - '*' 95 | Principal: 96 | CanonicalUser: 97 | Fn::GetAtt: 98 | - WebsiteOriginAccessIdentity 99 | - S3CanonicalUserId 100 | 101 | # Use CloudFront in front of the Remix API Gateway endpoint 102 | RemixCachePolicy: 103 | Type: AWS::CloudFront::CachePolicy 104 | Properties: 105 | CachePolicyConfig: 106 | Name: RemixCachePolicy 107 | DefaultTTL: 60 108 | MinTTL: 0 109 | MaxTTL: 60 110 | ParametersInCacheKeyAndForwardedToOrigin: 111 | HeadersConfig: 112 | HeaderBehavior: none 113 | EnableAcceptEncodingGzip: true 114 | QueryStringsConfig: 115 | QueryStringBehavior: all 116 | CookiesConfig: 117 | CookieBehavior: all 118 | 119 | # CloudFront Configuration 120 | CDN: 121 | Type: AWS::CloudFront::Distribution 122 | DependsOn: 123 | - WebsiteBucket 124 | - HttpApi 125 | Properties: 126 | DistributionConfig: 127 | Origins: 128 | - DomainName: 129 | Fn::GetAtt: 130 | - WebsiteBucket 131 | - DomainName 132 | Id: StaticOrigin 133 | S3OriginConfig: 134 | OriginAccessIdentity: 135 | Fn::Join: 136 | - / 137 | - - origin-access-identity 138 | - cloudfront 139 | - !Ref WebsiteOriginAccessIdentity 140 | OriginPath: '/website' 141 | - DomainName: 142 | Fn::Join: 143 | - '' 144 | - - Ref: HttpApi 145 | - '.execute-api.${self:provider.region}.amazonaws.com' 146 | Id: RemixOrigin 147 | # UNCOMMENT THIS TO SET THE X-Forwarded-Host header for API Gateway 148 | # OriginCustomHeaders: 149 | # - HeaderName: X-Forwarded-Host 150 | # HeaderValue: ${self:${self:provider.stage}.HOST} 151 | CustomOriginConfig: 152 | OriginProtocolPolicy: https-only 153 | OriginSSLProtocols: [TLSv1.2] 154 | 155 | # missing static assets will give a 403 from s3, so you can override the error response here 156 | # CustomErrorResponses: 157 | # - ErrorCachingMinTTL: 60 158 | # ErrorCode: 403 159 | # ResponseCode: 404 160 | # ResponsePagePath: /404 161 | # By default, all requests go to remix 162 | DefaultCacheBehavior: 163 | AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE] 164 | CachedMethods: [GET, HEAD, OPTIONS] 165 | Compress: true 166 | CachePolicyId: 167 | Ref: RemixCachePolicy 168 | TargetOriginId: RemixOrigin 169 | ViewerProtocolPolicy: redirect-to-https 170 | # Requests to /static go to S3 171 | CacheBehaviors: 172 | - PathPattern: 'static/*' 173 | AllowedMethods: 174 | - GET 175 | - HEAD 176 | CachedMethods: 177 | - GET 178 | - HEAD 179 | Compress: true 180 | ForwardedValues: 181 | QueryString: true 182 | Cookies: 183 | Forward: none 184 | TargetOriginId: StaticOrigin 185 | ViewerProtocolPolicy: redirect-to-https 186 | # Special rule for browser favicon requests to also go to S3 origin 187 | - PathPattern: '/favicon.*' 188 | AllowedMethods: 189 | - GET 190 | - HEAD 191 | CachedMethods: 192 | - GET 193 | - HEAD 194 | Compress: false 195 | ForwardedValues: 196 | QueryString: false 197 | Cookies: 198 | Forward: none 199 | TargetOriginId: StaticOrigin 200 | ViewerProtocolPolicy: redirect-to-https 201 | Comment: ${self:service}-${self:provider.stage} 202 | Enabled: true 203 | HttpVersion: http2 204 | PriceClass: PriceClass_100 205 | ViewerCertificate: 206 | CloudFrontDefaultCertificate: true 207 | Outputs: 208 | WebsiteBucketName: 209 | Value: 210 | Ref: WebsiteBucket 211 | DistributionID: 212 | Value: 213 | Ref: CDN 214 | WebsiteDomain: 215 | Value: 216 | Fn::GetAtt: [CDN, DomainName] 217 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "target": "ES2019", 10 | "strict": true, 11 | "baseUrl": "./", 12 | "paths": { 13 | "~/*": ["./app/*"] 14 | }, 15 | 16 | // Remix takes care of building everything in `remix build`. 17 | "noEmit": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------