diff --git a/examples/aws-remix-container/.dockerignore b/examples/aws-remix-container/.dockerignore new file mode 100644 index 000000000..25316b7aa --- /dev/null +++ b/examples/aws-remix-container/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.cache +build +public/build + + +# sst +.sst \ No newline at end of file diff --git a/examples/aws-remix-container/.eslintrc.cjs b/examples/aws-remix-container/.eslintrc.cjs new file mode 100644 index 000000000..4f6f59eee --- /dev/null +++ b/examples/aws-remix-container/.eslintrc.cjs @@ -0,0 +1,84 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/examples/aws-remix-container/.gitignore b/examples/aws-remix-container/.gitignore new file mode 100644 index 000000000..6a1b35d13 --- /dev/null +++ b/examples/aws-remix-container/.gitignore @@ -0,0 +1,8 @@ +node_modules + +/.cache +/build +.env + +# sst +.sst diff --git a/examples/aws-remix-container/Dockerfile b/examples/aws-remix-container/Dockerfile new file mode 100644 index 000000000..026c03329 --- /dev/null +++ b/examples/aws-remix-container/Dockerfile @@ -0,0 +1,45 @@ +# base node image +FROM node:18-bullseye-slim as base + +# set for base and all layer that inherit from it +ENV NODE_ENV production + +# Install all node_modules, including dev dependencies +FROM base as deps + +WORKDIR /myapp + +ADD package.json ./ +RUN npm install --include=dev + +# Setup production node_modules +FROM base as production-deps + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules +ADD package.json ./ +RUN npm prune --omit=dev + +# Build the app +FROM base as build + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD . . +RUN npm run build + +# Finally, build the production image with minimal footprint +FROM base + +WORKDIR /myapp + +COPY --from=production-deps /myapp/node_modules /myapp/node_modules + +COPY --from=build /myapp/build /myapp/build +COPY --from=build /myapp/public /myapp/public +ADD . . + +CMD ["npm", "start"] diff --git a/examples/aws-remix-container/README.md b/examples/aws-remix-container/README.md new file mode 100644 index 000000000..6c4d2168f --- /dev/null +++ b/examples/aws-remix-container/README.md @@ -0,0 +1,40 @@ +# Welcome to Remix! + +- 📖 [Remix docs](https://remix.run/docs) + +## Development + +Run the dev server: + +```shellscript +npm run dev +``` + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `npm run build` + +- `build/server` +- `build/client` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information. diff --git a/examples/aws-remix-container/app/entry.client.tsx b/examples/aws-remix-container/app/entry.client.tsx new file mode 100644 index 000000000..94d5dc0de --- /dev/null +++ b/examples/aws-remix-container/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/examples/aws-remix-container/app/entry.server.tsx b/examples/aws-remix-container/app/entry.server.tsx new file mode 100644 index 000000000..45db3229c --- /dev/null +++ b/examples/aws-remix-container/app/entry.server.tsx @@ -0,0 +1,140 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext +) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/examples/aws-remix-container/app/root.tsx b/examples/aws-remix-container/app/root.tsx new file mode 100644 index 000000000..61c8b983d --- /dev/null +++ b/examples/aws-remix-container/app/root.tsx @@ -0,0 +1,45 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; + +import "./tailwind.css"; + +export const links: LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/examples/aws-remix-container/app/routes/_index.tsx b/examples/aws-remix-container/app/routes/_index.tsx new file mode 100644 index 000000000..b5be85818 --- /dev/null +++ b/examples/aws-remix-container/app/routes/_index.tsx @@ -0,0 +1,39 @@ +import { Resource } from "sst"; +import { Cluster } from "ioredis"; +import { json } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; + +const redis = new Cluster( + [{ host: Resource.MyRedis.host, port: Resource.MyRedis.port }], + { + dnsLookup: (address, callback) => callback(null, address), + redisOptions: { + tls: {}, + username: Resource.MyRedis.username, + password: Resource.MyRedis.password, + }, + } +); + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export async function loader() { + const counter = await redis.incr("counter"); + + return json({ counter }); +} + +export default function Index() { + const data = useLoaderData(); + return ( +

+ Hit counter: {data.counter} +

+ ); +} diff --git a/examples/aws-remix-container/app/tailwind.css b/examples/aws-remix-container/app/tailwind.css new file mode 100644 index 000000000..303fe158f --- /dev/null +++ b/examples/aws-remix-container/app/tailwind.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + @apply bg-white dark:bg-gray-950; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/examples/aws-remix-container/package.json b/examples/aws-remix-container/package.json new file mode 100644 index 000000000..732ab3f46 --- /dev/null +++ b/examples/aws-remix-container/package.json @@ -0,0 +1,45 @@ +{ + "name": "aws-remix-container", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix vite:build", + "dev": "remix vite:dev", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "remix-serve ./build/server/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/node": "^2.12.1", + "@remix-run/react": "^2.12.1", + "@remix-run/serve": "^2.12.1", + "ioredis": "^5.4.1", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sst": "latest" + }, + "devDependencies": { + "@remix-run/dev": "^2.12.1", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "autoprefixer": "^10.4.19", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/examples/aws-remix-container/postcss.config.js b/examples/aws-remix-container/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/examples/aws-remix-container/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/aws-remix-container/public/favicon.ico b/examples/aws-remix-container/public/favicon.ico new file mode 100644 index 000000000..8830cf682 Binary files /dev/null and b/examples/aws-remix-container/public/favicon.ico differ diff --git a/examples/aws-remix-container/public/logo-dark.png b/examples/aws-remix-container/public/logo-dark.png new file mode 100644 index 000000000..b24c7aee3 Binary files /dev/null and b/examples/aws-remix-container/public/logo-dark.png differ diff --git a/examples/aws-remix-container/public/logo-light.png b/examples/aws-remix-container/public/logo-light.png new file mode 100644 index 000000000..4490ae793 Binary files /dev/null and b/examples/aws-remix-container/public/logo-light.png differ diff --git a/examples/aws-remix-container/sst-env.d.ts b/examples/aws-remix-container/sst-env.d.ts new file mode 100644 index 000000000..261f14f5b --- /dev/null +++ b/examples/aws-remix-container/sst-env.d.ts @@ -0,0 +1,25 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +import "sst" +export {} +declare module "sst" { + export interface Resource { + "MyRedis": { + "host": string + "password": string + "port": number + "type": "sst.aws.Redis" + "username": string + } + "MyService": { + "service": string + "type": "sst.aws.Service" + "url": string + } + "MyVpc": { + "bastion": string + "type": "sst.aws.Vpc" + } + } +} diff --git a/examples/aws-remix-container/sst.config.ts b/examples/aws-remix-container/sst.config.ts new file mode 100644 index 000000000..cca260899 --- /dev/null +++ b/examples/aws-remix-container/sst.config.ts @@ -0,0 +1,26 @@ +/// + +export default $config({ + app(input) { + return { + name: "aws-remix-container", + removal: input?.stage === "production" ? "retain" : "remove", + home: "aws", + }; + }, + async run() { + const vpc = new sst.aws.Vpc("MyVpc", { bastion: true }); + const redis = new sst.aws.Redis("MyRedis", { vpc }); + const cluster = new sst.aws.Cluster("MyCluster", { vpc }); + + cluster.addService("MyService", { + link: [redis], + public: { + ports: [{ listen: "80/http", forward: "3000/http" }], + }, + dev: { + command: "npm run dev", + }, + }); + } +}); diff --git a/examples/aws-remix-container/tailwind.config.ts b/examples/aws-remix-container/tailwind.config.ts new file mode 100644 index 000000000..14d0f00ce --- /dev/null +++ b/examples/aws-remix-container/tailwind.config.ts @@ -0,0 +1,22 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + fontFamily: { + sans: [ + '"Inter"', + "ui-sans-serif", + "system-ui", + "sans-serif", + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + '"Noto Color Emoji"', + ], + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/examples/aws-remix-container/tsconfig.json b/examples/aws-remix-container/tsconfig.json new file mode 100644 index 000000000..9d87dd378 --- /dev/null +++ b/examples/aws-remix-container/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/examples/aws-remix-container/vite.config.ts b/examples/aws-remix-container/vite.config.ts new file mode 100644 index 000000000..54066fb7a --- /dev/null +++ b/examples/aws-remix-container/vite.config.ts @@ -0,0 +1,16 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths(), + ], +}); diff --git a/www/src/assets/docs/start/start-remix-container.png b/www/src/assets/docs/start/start-remix-container.png new file mode 100644 index 000000000..17ce75914 Binary files /dev/null and b/www/src/assets/docs/start/start-remix-container.png differ diff --git a/www/src/content/docs/docs/start/aws/nextjs.mdx b/www/src/content/docs/docs/start/aws/nextjs.mdx index 3ba92bb80..1ad917ec2 100644 --- a/www/src/content/docs/docs/start/aws/nextjs.mdx +++ b/www/src/content/docs/docs/start/aws/nextjs.mdx @@ -542,7 +542,7 @@ You can use any stage name here but it's good to create a new stage for producti Congrats! Your app should now be live! -![SST Next.js app](../../../../../assets/docs/start/start-nextjs-container.png) +![SST Next.js container app](../../../../../assets/docs/start/start-nextjs-container.png) --- diff --git a/www/src/content/docs/docs/start/aws/remix.mdx b/www/src/content/docs/docs/start/aws/remix.mdx index 125122663..7f5676451 100644 --- a/www/src/content/docs/docs/start/aws/remix.mdx +++ b/www/src/content/docs/docs/start/aws/remix.mdx @@ -3,7 +3,26 @@ title: Remix on AWS with SST description: Create and deploy a Remix app to AWS with SST. --- -We are going to create a Remix app, add an S3 Bucket for file uploads, and deploy it to AWS using SST. +There are two ways to deploy a Remix app to AWS with SST. + +1. [Serverless](#serverless) +2. [Containers](#containers) + +We'll use both to build a couple of simple apps below. + +--- + +#### Examples + +We also have a few other Remix examples that you can refer to. + +- [Enabling streaming in your Remix app](/docs/examples/#aws-remix-streaming) + +--- + +## Serverless + +We are going to create a Remix app, add an S3 Bucket for file uploads, and deploy it to using the `Remix` component. :::tip[View source] You can [view the source](https://github.com/sst/ion/tree/dev/examples/aws-remix) of this example in our repo. @@ -13,12 +32,12 @@ Before you get started, make sure to [configure your AWS credentials](/docs/iam- --- -## 1. Create a project +### 1. Create a project Let's start by creating our project. ```bash -npx create-remix@latest +npx create-remix@latest aws-remix cd aws-remix ``` @@ -26,7 +45,7 @@ We are picking all the default options. --- -#### Init SST +##### Init SST Now let's initialize SST in our app. @@ -39,7 +58,7 @@ Select the defaults and pick **AWS**. This'll create a `sst.config.ts` file in y --- -#### Start dev mode +##### Start dev mode Run the following to start dev mode. This'll start SST and your Remix app. @@ -51,7 +70,7 @@ Once complete, click on **MyWeb** in the sidebar and open your Remix app in your --- -## 2. Add an S3 Bucket +### 2. Add an S3 Bucket Let's allow public `access` to our S3 Bucket for file uploads. Update your `sst.config.ts`. @@ -63,7 +82,7 @@ const bucket = new sst.aws.Bucket("MyBucket", { Add this above the `Remix` component. -#### Link the bucket +##### Link the bucket Now, link the bucket to our Remix app. @@ -75,7 +94,7 @@ new sst.aws.Remix("MyWeb", { --- -## 3. Create an upload form +### 3. Create an upload form Add the upload form client in `app/routes/_index.tsx`. Replace the `Index` component with: @@ -113,7 +132,7 @@ export default function Index() { --- -## 4. Generate a pre-signed URL +### 4. Generate a pre-signed URL When our app loads, we'll generate a pre-signed URL for the file upload and use it in the form. @@ -153,7 +172,7 @@ Head over to the local Remix app in your browser, `http://localhost:5173` and tr --- -## 5. Deploy your app +### 5. Deploy your app Now let's deploy your app to AWS. @@ -169,6 +188,272 @@ Congrats! Your site should now be live! --- +## Containers + +We are going to build a hit counter Remix app with Redis. We’ll the deploy it to AWS in a container using the `Cluster` component. + +:::tip[View source] +You can [view the source](https://github.com/sst/ion/tree/dev/examples/aws-remix-container) of this example in our repo. +::: + +Before you get started, make sure to [configure your AWS credentials](/docs/iam-credentials#credentials). + +--- + +### 1. Create a project + +Let's start by creating our app. + +```bash +npx create-remix@latest aws-remix-container +cd aws-remix-container +``` + +We are picking all the default options. + +--- + +##### Init SST + +Now let's initialize SST in our app. + +```bash +npx sst@latest init +``` + +Select the defaults and pick **AWS**. This'll create a `sst.config.ts` file in your project root. + +--- + +### 2. Add a Cluster + +To deploy our Remix app in a container, we'll use [AWS Fargate](https://aws.amazon.com/fargate/) with [Amazon ECS](https://aws.amazon.com/ecs/). Replace the `run` function in your `sst.config.ts`. + +```js title="sst.config.ts" {9-11} +async run() { + const vpc = new sst.aws.Vpc("MyVpc", { bastion: true }); + const cluster = new sst.aws.Cluster("MyCluster", { vpc }); + + cluster.addService("MyService", { + public: { + ports: [{ listen: "80/http", forward: "3000/http" }], + }, + dev: { + command: "npm run dev", + }, + }); +} +``` + +This creates a VPC with a bastion host, an ECS Cluster, and adds a Fargate service to it. + +The `dev.command` tells SST to run our Remix app locally in dev mode. + +--- + +### 3. Add Redis + +Let's add an [Amazon ElastiCache](https://aws.amazon.com/elasticache/) Redis cluster. Add this below the `Vpc` component in your `sst.config.ts`. + +```js title="sst.config.ts" +const redis = new sst.aws.Redis("MyRedis", { vpc }); +``` + +This shares the same VPC as our ECS cluster. + +--- + +#### Link Redis + +Now, link the Redis cluster to the container. + +```ts title="sst.config.ts" {3} +cluster.addService("MyService", { + // ... + link: [redis], +}); +``` + +This will allow us to reference the Redis cluster in our Remix app. + +--- + +#### Install a tunnel + +Since our Redis cluster is in a VPC, we'll need a tunnel to connect to it from our local machine. + +```bash "sudo" +sudo npx sst tunnel install +``` + +This needs _sudo_ to create a network interface on your machine. You'll only need to do this once on your machine. + +--- + +#### Start dev mode + +Start your app in dev mode. + +```bash +npx sst dev +``` + +This will deploy your app, start a tunnel in the **Tunnel** tab, and run your Remix app locally in the **MyServiceDev** tab. + +--- + +### 4. Connect to Redis + +We want the `/` route to increment a counter in our Redis cluster. Let's start by installing the npm package we'll use. + +```bash +npm install ioredis +``` + +We'll add a `loader` to increment the counter. + +```ts title="app/routes/_index.tsx" {2} +const redis = new Cluster( + [{ host: Resource.MyRedis.host, port: Resource.MyRedis.port }], + { + dnsLookup: (address, callback) => callback(null, address), + redisOptions: { + tls: {}, + username: Resource.MyRedis.username, + password: Resource.MyRedis.password, + }, + } +); + +export async function loader() { + const counter = await redis.incr("counter"); + + return json({ counter }); +} +``` + +:::tip +We are directly accessing our Redis cluster with `Resource.MyRedis.*`. +::: + +Add the relevant imports. + +```ts title="app/routes/_index.tsx" +import { Resource } from "sst"; +import { Cluster } from "ioredis"; +import { json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +``` + +Let's now use this in our `Index` component. + +```ts title="app/routes/_index.tsx" +export default function Index() { + const data = useLoaderData(); + + return ( +

+ Hit counter: {data.counter} +

+ ); +} +``` + +--- + +#### Test your app + +Let's head over to `http://localhost:5173` in your browser and it'll show the current hit counter. + +You should see it increment every time you **refresh the page**. + +--- + +### 5. Deploy your app + +To deploy our app we'll add a `Dockerfile`. + +
+View Dockerfile + +```diff lang="dockerfile" title="Dockerfile" +# Based on https://github.com/remix-run/blues-stack/blob/main/Dockerfile + +# base node image +FROM node:18-bullseye-slim as base + +# set for base and all layer that inherit from it +ENV NODE_ENV production + +# Install all node_modules, including dev dependencies +FROM base as deps + +WORKDIR /myapp + +ADD package.json ./ +RUN npm install --include=dev + +# Setup production node_modules +FROM base as production-deps + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules +ADD package.json ./ +RUN npm prune --omit=dev + +# Build the app +FROM base as build + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD . . +RUN npm run build + +# Finally, build the production image with minimal footprint +FROM base + +WORKDIR /myapp + +COPY --from=production-deps /myapp/node_modules /myapp/node_modules + +COPY --from=build /myapp/build /myapp/build +COPY --from=build /myapp/public /myapp/public +ADD . . + +CMD ["npm", "start"] +``` +
+ +:::tip +You need to be running [Docker Desktop](https://www.docker.com/products/docker-desktop/) to deploy your app. +::: + +Let's also add a `.dockerignore` file in the root. + +```bash title=".dockerignore" +node_modules +.cache +build +public/build +``` + +Now to build our Docker image and deploy we run: + +```bash +npx sst deploy --stage production +``` + +You can use any stage name here but it's good to create a new stage for production. + +Congrats! Your app should now be live! + +![SST Remix container app](../../../../../assets/docs/start/start-remix-container.png) + +--- + ## Connect the console As a next step, you can setup the [SST Console](/docs/console/) to _**git push to deploy**_ your app and monitor it for any issues.