Dockerize RedwoodJS

Hi all :wave:

In regards to some recent Docker discussions, I thought a thread to summarize the efforts and discussions related to this would be a good next step. Below is a starting point of some topics to iron out and I invite everyone to participate and give input.

image

Topics

CLI

  • Command to setup and generate configuration(s)
  • yarn redwood setup deploy docker: init the template files
    • options? E.g. kubernetes, etc.
  • yarn redwood deploy docker: I don’t think this makes sense. The recommendation might be for projects to create their own custom package.json script for deployment.
  • […]

Development

  • Is there requests or audience for local docker development?
    • @thedavid answer: yes, there have been. I think it would be a great addition and use-case for Docker Compose. Using file mounting could work seamlessly.
  • Database choice (SQLlite, MySQL/MariaDB, Postgres, …)
  • How will yarn rwt ... work?
  • Link together development in a docker-compose.yaml
  • […]

Production

Question:
Are there circumstances requiring the option to either separate Web and API into two services or just one?

  • best practices would suggest unique services
  • however, given yarn redwood serve running both the API server and serving the static Web files, I can forsee people wanting to “just get started prototyping” with a single service.
    • @jeliasson answer: “just get started prototyping” feels more of a development-phase rather than production. For production I think it makes sense to abstract the services and make sure to use a web server that is more up to the task of serving static contents and the rules that comes with that. I’m not familiar with the web server being used with yarn redwood serve, but I guess it comes down to some benchmarks. In the end; Production should be fast and flexible. :zap:

API

  • Make a slim production image (Dockerize api-side)
  • Figure out where and how database migration and seed would occur (see this comment for context)
  • […]

Web

  • Web server choice (Nginx vs Caddy vs …)
    • @thedavidprice answer: why not yarn redwood serve web? My preference would be to keep the service minimal. If you want to also use Nginx, etc., that could be an additional implementation per hosting/infra. I just think there’s going to be an infinite amount of hosting options and alternatives for performance and caching, but all very specific to the host/infra being used.
    • @jeliasson answer: As Redwood is opinionated, I think it makes sense to choose a web server that does the best job. If that is the “built in one”, so be it. If not, we should aim for something that is more suitable and has been battle tested. As we’re talking about Docker, the hosting platform should not make any difference and it will (should) run exactly the same on all hosting platforms. Everything else is just optional configuration, and preferably with something that has great documentation for that.
  • Baseline configuration (Cache, CORS, etc)
  • […]

Other

  • Overall file structure and naming of Dockerfiles (production vs development files?).
  • Is earhly just another dependency or something that would make sense somehow?
  • Should we maintain various CI-CD pipeline examples for deployments? (GitHub Actions, GitLabs, …)
    • I think these would make for a good Cookbook and/or Docs.
  • …

Benchmarks

See redwoodjs-docker repository.

References

Edit Strange. I could not save my edits without getting this error message. @thedavid Do you know why? In the meantime, I moved the /cc below to a code block.

/cc @ajcwebdev @benkraus @danny @peterp @pi0neerpat @pickettd @nerdstep @wiezmankimchi
8 Likes

@jeliasson Thank you for getting this going! I’ve made this post a Wiki — if anyone has trouble editing, just let me know.

And if you want to edit, just use this icon in the top right of the post:
Screen Shot 2021-07-22 at 4.24.38 PM

General Thoughts

I think a great first step would be to create a canonical Dockerfile that includes both API and Web, does not include unnecessary code or packages, and uses yarn redwood serve to run.

  • uses lightweight Docker image (e.g. slim, apline, etc)
  • removes all unnecessary code and packages (i.e. just built directories and any other server-required dependencies)
  • follow best practices for Dockerfile Node.js permission, structure, etc.

This would be a great first-step for a yarn redwood setup deploy docker command → a singular Dockerfile that’s great for getting started.

Once that’s done, then we should work towards:

  1. Specific Dockerfiles for both Web and API
  2. Local Dev Option (e.g. using Docker Compose)
  3. Kubernetes config and/or other deployment config + performance options (e.g. Nginx)
  4. …
4 Likes

Update for anyone following the Dockerization story

We’ve got a build working on fly.io but we need to merge a PR first to get it in the shape we really want, so we’re right on the cusp of having both:

  1. Solid Dockerfiles
  2. A deployment story for those images

I’d recommend people check out Fly in the mean time if this is something that sounds interesting/useful to them, I’ve written up a couple tutorials and example repos here:

1 Like

Alright, we’ve got a working implementation. Check out the code here for the barebones version, or here for the finished tutorial blog. Huge thanks to Joshua Sierles for doing the majority of the heavy lifting here.

NOTE: This will not be the end state of deploying Redwood apps to Fly. We are still optimizing the build and there will be a yarn rw deploy fly command to set this up automatically. However, if you want to start spiking something out now, you can follow these steps manually.

Creating and Deploying a Redwood App on Fly from Scratch

yarn create redwood-app redwood-fly
cd redwood-fly

Create Dockerfile, .dockerignore, fly.toml, and .env

Normally .env is contained in the root of your project, but as of now it will need to be contained inside your api/db folder due to Docker weirdness.

touch fly.toml Dockerfile .dockerignore api/db/.env
rm -rf .env .env.defaults

prisma.schema

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime @default(now())
}

Setup home page and BlogPosts cell

yarn rw g page home /
yarn rw g cell BlogPosts
yarn rw g scaffold post

BlogPostsCell.js

// web/src/components/BlogPostsCell/BlogPostsCell.js

export const QUERY = gql`
  query POSTS {
    posts {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => (
  <div style={{ color: 'red' }}>Error: {error.message}</div>
)

export const Success = ({ posts }) => {
  return posts.map((post) => (
    <article key={post.id}>
      <header>
        <h2>{post.title}</h2>
      </header>

      <p>{post.body}</p>
      <time>{post.createdAt}</time>
    </article>
  ))
}

HomePage.js

// web/src/pages/HomePage/HomePage.js

import BlogPostsCell from 'src/components/BlogPostsCell'
import { MetaTags } from '@redwoodjs/web'

const HomePage = () => {
  return (
    <>
      <MetaTags
        title="Home"
        description="This is the home page"
      />

      <h1>Redwood+Fly 🦅</h1>
      <BlogPostsCell />
    </>
  )
}

export default HomePage

DATABASE_URL

Include DATABASE_URL in api/db/.env. See this post for instructions on quickly setting up a remote database on Railway.

DATABASE_URL=postgresql://postgres:password@containers-us-west-10.railway.app:5513/railway
yarn rw prisma migrate dev --name fly-away
yarn rw dev

Open http://localhost:8910/posts to create a test post and return to http://localhost:8910/ to see the result.

redwood.toml

Inside redwood.toml set the apiUrl to the following with the name of your project instead of redwood-fly.

[web]
  title = "Redwood App"
  port = 8910
  apiUrl = "https://redwood-fly.fly.dev/api/graphql"
  includeEnvironmentVariables = []
[api]
  port = 8911
[browser]
  open = true

Deploy to Fly

fly launch will configure your project.

fly launch --name redwood-fly

Dockerfile

This generates the following Dockerfile.

FROM node:14-alpine as base

WORKDIR /app

COPY package.json package.json
COPY web/package.json web/package.json
COPY api/package.json api/package.json
COPY yarn.lock yarn.lock
RUN yarn install --frozen-lockfile

COPY redwood.toml .
COPY graphql.config.js .

FROM base as web_build

COPY web web
RUN yarn rw build web

FROM base as api_build

COPY api api
RUN yarn rw build api

FROM node:14-alpine

WORKDIR /app

# Only install API packages to keep image small
COPY api/package.json .

RUN yarn install && yarn add react react-dom @redwoodjs/api-server @redwoodjs/internal prisma

COPY graphql.config.js .
COPY redwood.toml .
COPY api api

COPY --from=web_build /app/web/dist /app/web/dist
COPY --from=api_build /app/api/dist /app/api/dist
COPY --from=api_build /app/api/db /app/api/db
COPY --from=api_build /app/node_modules/.prisma /app/node_modules/.prisma

# Entrypoint to @redwoodjs/api-server binary
CMD [ "yarn", "rw-server", "--port", "8910" ]

fly.toml

It will also generate the following fly.toml file.

app = "redwood-fly"

kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[deploy]
  release_command = "npx prisma migrate deploy --schema '/app/api/db/schema.prisma'"

[env]
  PORT = "8910"

[experimental]
  allowed_public_ports = []
  auto_rollback = true

[[services]]
  http_checks = []
  internal_port = 8910
  processes = ["app"]
  protocol = "tcp"
  script_checks = []

  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

  [[services.ports]]
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

If you are on an M1 you will likely run into issues. You can add the --remote-only flag to build the Docker image with Fly’s remote builder to avoid this issue and also speed up your build time.

fly deploy --remote-only

If all went according to plan you will see the following message:

Monitoring Deployment

1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing]
--> v1 deployed successfully

Live example - https://redwood-fly.fly.dev/

Test your endpoint

Warning: As of Redwood v0.36.x, Redwood’s API is open by default unless you specify an environment variable for secure services. This will be changing very soon in one of the upcoming minor releases before the v1 release candidate. If you follow this tutorial as is, your endpoint will be trollable.

Hit https://redwood-fly.fly.dev/api/graphql with your favorite API tool or curl.

Check Redwood Version

query REDWOOD_VERSION {
  redwood {
    version
  }
}

Output:

{
  "data": {
    "redwood": {
      "version": "0.36.4"
    }
  }
}

Query for all posts

query POSTS {
  posts {
    id
    title
    body
    createdAt
  }
}

Output:

{
  "data": {
    "posts": [
      {
        "id": 1,
        "title": "This is a post",
        "body": "Yeah it is",
        "createdAt": "2021-09-09T20:10:58.985Z"
      }
    ]
  }
}

Create a post

mutation CREATE_POST_MUTATION {
  createPost(
    input: {
      title:"this is a title",
      body:"this is a body"
    }
  ) {
    id
    title
    body
    createdAt
  }
}

Output:

{
  "data": {
    "createPost": {
      "id": 2,
      "title": "this is a title",
      "body": "this is a body",
      "createdAt": "2021-09-20T02:00:24.899Z"
    }
  }
}

Delete a post

mutation DELETE_POST_MUTATION {
  deletePost(
    id: 2
  ) {
    id
    title
    body
    createdAt
  }
}

Output:

{
  "data": {
    "deletePost": {
      "id": 2,
      "title": "this is a title",
      "body": "this is a body",
      "createdAt": "2021-09-20T02:00:24.899Z"
    }
  }
}
2 Likes

Wow! Thank you to you and Joshua for all this! I’ve been wanting to Docker-ify my app (mostly out of curiosity) and y’all come out with this right as I’m at the stage to do so! It was almost a copy-paste of the Dockerfile you sent and I was up and running.

I did split my API and web-sides into two separate images - mostly because I can and I’d love to try and scale each side independently; posting each side’s Dockerfile for reference (again, they’re pretty much identical to what Anthony posted):

API:

#==
# Build
FROM node:14-alpine as build

WORKDIR /app

COPY package.json .
COPY yarn.lock .

COPY api/package.json api/package.json

RUN yarn install --frozen-lockfile

COPY redwood.toml .
COPY graphql.config.js .
COPY babel.config.js .

COPY api api
RUN yarn rw build api

#==
# Serve
FROM node:14-alpine

WORKDIR /app

COPY api/package.json api/package.json

RUN yarn install && yarn add react react-dom @redwoodjs/cli

COPY graphql.config.js .
COPY redwood.toml .
COPY api api

COPY --from=build /app/api/dist /app/api/dist
COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma

EXPOSE 8911

CMD [ "yarn", "rw", "serve", "api", "--port", "8911" ]

Web:

# ==
# Build
FROM node:14-alpine as build

WORKDIR /app

COPY package.json .
COPY yarn.lock .

COPY redwood.toml .
COPY graphql.config.js .
COPY babel.config.js .

COPY web/package.json web/package.json

RUN yarn install --frozen-lockfile

COPY web web

COPY config/cerberus/.env .env

RUN yarn rw build web

#==
# Serve
FROM node:14-alpine

WORKDIR /app

COPY redwood.toml .

COPY --from=build /app/web/dist web/dist

RUN yarn add react react-dom @redwoodjs/cli

EXPOSE 8910

CMD [ "yarn", "rw", "serve", "web" ]

And then, of course, I can run these as two separate containers or as part of a Docker-Swarm service (the benefits? Don’t as me! I’m just hitting keys).

Some takeaways I have are:

  • I didn’t have to make a single change to any code :+1:
  • Not specific to Redwood - or by its fault at all - but I had to put both containers behind an API gateway so that the endpoint I was reaching the web-side on was the one it was going to use to reach the API - yay CORS.
  • The web-side does not play well with being placed anywhere other than the root of an endpoint. I was able to prepend the path this container was reachable at to the web’s built resources, using only 1 line of Webpack configuration; however, Redwood’s router reads the current location from the browser’s URL, so that path was being added to the router - causing it to throw a 404.
    • In production, personally, this wouldn’t cause an issue because if I had multiple apps on one domain I’d split each onto its own sub-domain. But for developing, it’d be nice to be able to put two apps up and reach both through a single endpoint.
  • Size-wise, my API’s clocking in at 748.78 MB, web is at 630 MB. This is with pulling in the CLI package for both images (~260MB when I did a quick install of only it in an empty directory).
    • The API could be improved to only use the @redwoodjs/api-server package, which is in the ~5.3MB range. The speed to build that image is what I’m focused on, the CLI took about a minute to install on its own, api-server was done in 10 or so seconds.
    • My web-side is, probably, a lost cause. The configuration I have above is just using the rw serve web command, and I’d imagine a proper image would base off Ngnix or similar - which I’d assume will just raise that size up even higher.
  • The gateway got around the need to pass --rootPath to my invocation of the serve command, on the API side. Just wanted to add that.
2 Likes

Awesome, thanks for the notes, @realStandal! Quick update on our side, we’re currently in talks with the Qovery folks and will hopefully have some docs for deploying there as well in the near future.

The plot thickens!

I’ve replaced the @redwoodjs/cli dependency on the API-side to use @redwoodjs/api-server. It seems @redwoodjs/internal is one of its dependencies but isn’t added in its package.json.

# Dockerfile.api
- RUN yarn install && yarn add react react-dom @redwoodjs/cli
+ RUN yarn install && yarn add react react-dom @redwoodjs/api-server @redwoodjs/internal

COPY graphql.config.js .
...

- CMD [ "yarn", "rw", "serve", "api", "--port", "8911" ]
+ CMD [ "yarn", "rw-api-server", "--port", "8911" ]

For the web, I replaced the dependency on @redwoodjs/cli for http-server with a catch-all redirect.

# Dockerfile.web
- RUN yarn add react react-dom @redwoodjs/cli
+ RUN yarn add react react-dom http-server

EXPOSE 8080

- CMD [ "yarn", "rw", "serve", "web" ]
+ CMD [ "yarn", "http-server", "web/dist", "--proxy", "http://localhost:8080?" ]
  • The final API image decreased from 748mb to 530
  • The final web image decreased from 630 to 136

Pleasantly wrong on the web-side.

I’ve also been working on it having a place on IBM’s Code Engine.

2 Likes

This is great, we’re working on some Docker golf of our own so I’ve forwarded this over to the Fly team.

Curious what appeals to you about IBM’s Code Engine? Just wondering cause I’ve never used it myself.

Nice! (What do you mean by Docker golf? lol - just that y’all are going back and forth?)

I made a few more updates to get it into working order (to deploy to code engine), mostly around starting a container:

  • I added USER 1100:1100 to both side’s images - done during the serve stage so the commands which run these sides aren’t being ran as root. (reference).
  • I swapped out http-server for local-web-server on the web. It provides a bit more control and configuration (used below).
  • I moved all CMD declarations on both images to ENTRYPOINT, leaving CMD open for overwriting.
  • Updated ports to follow Redwood’s defaults - the API will use 8911, web 8910.
  • Removed yarn from each side’s ENTRYPOINT command, installing both services used as global dependencies. This follows suit with another recommendation from Code Engine surrounding performance.

Here is the gist of each side’s Dockerfile - which I’ve used to deploy to Code Engine. (Again, these changes are focused in the “Serve” stages)

I do have rw deploy and rw setup commands created which will integrate with IBM’s CLI to help ease and configure deploying - have even been able to deploy to code engine using them. I’ll probably sit on them if/until Redwood’s Docker story fleshes out a bit more - so I can point to a rw setup docker command to be ran alongside Code Engine’s.

Curiosity and personal preference, I’ve admired IBM since I was real young. But, this was mainly just for science. From the four providers I looked at (IBM, GCP, Azure, AWS), IBM had the lowest costs for their serverless containers and their functions do not play well with how Redwood’s are currently setup.

To not turn this into my own personal “bash AWS” I’ll just leave it at I’m not a huge fan of anything with Amazon related to it - emotionally driven opinions :grinning_face_with_smiling_eyes: But from a practical standpoint, they are probably the best choice - particularly for start ups, their freebies look like they could go a long way. Not out of kindness, though, I’m sure. All that to say I’m always happy to experiment with options that aren’t AWS and which flip the bird at lock-in.

1 Like

Ahhh, my apologies, it’s a play on the term code golf.

Code golf is a type of recreational computer programming competition in which participants strive to achieve the shortest possible source code that implements a certain algorithm.

Meaning we are trying to get the smallest possible Docker image that is able to run the application.

All that to say I’m always happy to experiment with options that aren’t AWS and which flip the bird at lock-in.

Same, always interested in exploring alternative deployment methods. “Universal deployment machine,” would imply you can deploy to something that isn’t AWS.

All these changes looks great! Especially the change from CMD to ENTRYPOINT.

What is your stance on the front end webserver? Considering you changed http-server to local-http-server, to allow more control, would it make more sense to opt for Nginx or Caddy that is wildly adopted and highly customizable?

I have only used http-server for local development and don’t know how much e.g. local-http-server vs nginx or caddy weights or how much build time it adds.

Personally I considering using Caddy, where I currently use nginx described here.

@ajcwebdev @realStandal @thedavid
Should we coordinate the state of our Docker files in a mutual repo and maybe create a CI-pipe to test and benchmark these? Ultimately we find a baseline that would hopefully end up in the official Redwood repo (after some disussions on how we could approach local docker development etc).

Ahh, it makes so much sense now that you point it out lmao

I really think the API side can still be cut back. I’m going to say the web-side is a lost cause again, cause that seemed to work out well before.

None really, I choose the two I did out of simplicity - I knew I wasn’t pushing the app with the intent on keeping it in production and haven’t spent enough time fiddling with web-server’s to have justified spending a day wrestling with one.

Just from quickly looking over Caddy, it looks really nice - I’m tempted to start hacking away on it now. My main concern would be loosing flexibility (which it doesn’t look like we would). One of the reasons I made the ENTRYPOINT change was to keep the ambiguity during deployment. When I deployed to code engine, I had to add a rewrite rule that directed certain requests at the web-side to the API. However, I didn’t want to have to bake that URL into the image itself, so that one RW project could be deployed to many different providers/regions - without multiple images for each. The latest setup I shared let’s that be the case, where I can add -r '/api/(.*) -> https://api.BLAH.REGION.codeengine.appdomain.cloud/$1' as an entry to CMD.

I do lean more towards using something like Caddy - over local-web-server or even RW’s rw serve web - but I definitely see the desire to keep RW’s tooling in place. Best case, swapping out may even mean we can completely remove Node from the image’s final stage, right? Sounds like an easy place to save a bit of space.

I’m all for it :+1:

Yes, and I’m pretty sure nginx:alpine would be sufficient. I currently run nginx as final stage.

I don’t know IBM’s Code Engine, but could you add these rewrites rules on a edge proxy somewhere? I.e. load balanace /api request directly to the api container(s) without the need of hitting the web container(s).

Let me know if you come up with something cool. Havn’t had the time to play around with Caddy properly but definitely looks cool. :slight_smile:

Love it, the more data the better.

Just skimming through the comments right now. If this was my app, I’d consider a service that builds web/dist and then throws away everything else web related. Would then handle assets via Nginx config. Could be included with API as individual service or as a distinct service.

But I’d make that decision based on how I’m handling my static assets — e.g. use Cloudflare as my CDN and adjust deployment process accordingly (maybe I wouldn’t even need Nginx). You could even deploy the assets to Netlify CDN via their CLI (yes, that’s a thing!).

My point: there will never be one Docker setup to rule them all as requirements will vary based on infra, hosting, and networking. So I’d defer to whatever feels like the most foundational parts people could then modify per their requirements and setup.

1 Like

I converted my web-side’s Dockerfile to extend nginx:1.21.3-alpine, dropped the final image from 136MB down to 27 (from 630 to 27, what a journey).

Shoutout to the configuration on @jeliasson’s Kubernetes post - I did have to add include mime.types; under the http block to get my stylesheets to load.

Did the same using caddy:2.4.5-alpine, it’s at 44MB. Caddy does require persistent volumes; but if you were setting up TLS certificates for your nginx-based build, you’d need them too anyway, right?

Just from setting the two up, I think it’d be easy enough to have a cookbook about swapping one out for the other (or X out for Y, for that matter), if that was someone’s desire - it took me about 10 minutes to swap Nginx over to Caddy, most of which was just bouncing between and reading pages on their docs.

From having no experience with either, Caddy was quite a lot easier to get started with - their documentation and forum felt a lot more approachable. Had I not had Jeliasson’s post, I would have spent a lot more time on Nginx’s configuration.

And then here are the final files:


I’m assuming this is what you meant?

Very reasonable imo

2 Likes

@realStandal I’m glad the Kubernetes post helped out. It was fun writing it :slight_smile:

:exploding_head: Whoa. Well done!

Pretty much. Sure, you could build the image along with your TLS certificates and thus not need a “persistent storage”. This is where a HTTP proxy in front of api and web would be beneficial. This is definitely the case if you would be using Let’s Encrypt and running more than one replica.

This is great! I’ll finish up our testing repo with a copy-pasta CI-pipeline this weekend and get back to you.

2 Likes

Looking forward to seeing it all put together.

Thank you for taking the time to educate, so is the lack of security between the proxy and container negligible because the container’s only being accessed through it, or does that connection inherit it through the proxy? Or am I off the ball completely :joy:

1 Like

You’re right on the ball. Depending on your setup, I would say it’s negligible. You could have a proxy in front of everything that does the heavy TLS termination and then forward that traffic, unencrypted, to the respective containers ([client] <HTTPS> [proxy] <HTTP> [containers]).

DM me on Discord if you’d like to discuss it further. :v:

2 Likes

What if we publish a basic Nginx container for Redwood that handles /web/dist?

Easy to create a repo and a GH action for publishing.

fwiw

1 Like