wasp-lang / open-saas

A free, open-source SaaS app starter for React & Node.js with superpowers. Production-ready. Community-driven.
https://opensaas.sh
MIT License
5.92k stars 570 forks source link

deploy open-saas on Google Cloud Platform (firebase + cloud run) #178

Open TomDarmon opened 2 weeks ago

TomDarmon commented 2 weeks ago

Hello, I've recently gone through the steps to deploy open-saas on GCP and I believe it could be useful to document it here for now.

This could be added to the real documentation in the future, for now I'll just detail what I did to make it work.

A few prerequisites: • Have a GCP account & project with billing enabled (you might succeed without, but that's what I had). • Successfully run the open-saas template locally with, the appropriate env variable and an emailSender not set to Dummy.

Step 1 - GCP & firebase setup:

First, login to GCP and populate the default credentials. This step is important, it will ensure you don't get access issues. Run the 2 command separately and login each time: gcloud auth login gcloud auth application-default login

In your GCP project, enable the needed APIs:

# Enable the Artifact Registry API - storing docker images
gcloud services enable artifactregistry.googleapis.com

# Enable the Cloud Run API - running serverless containers for the backend
gcloud services enable run.googleapis.com

We will be building our Docker image and storing it in GCP, therefore we need to create an artifact registry where we will push the image:

# REMEMBER THE NAME AND REGION, IN LATER STEPS YOU WILL USE THEM
gcloud artifacts repositories create open-saas \
    --repository-format=docker \
    --location=europe-west1 \
    --description="Docker repository for Open SaaS"

For the firebase setup:

Step 2 - Deploy the frontend with firebase hosting

Follow the step 1 and 3 to get the build of the frontend (give a fake REACT_APP_API_URL for now, since we don't have it yet) --> https://wasp-lang.dev/docs/0.11.8/advanced/deployment/manually#1-generating-deployable-code

Add this firebase.json and .firebashrc at the root of your repo, optionally you can put them elsewhere but you need to adapt the paths and where you will run the firebase commands:

firebase.json

{
  "hosting": {
    "public": "app/.wasp/build/web-app/build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
  }
}

.firebaserc

{
  "projects": {
    "default": {FILL_YOUR_FULL__GCP_PROJECT_NAME}
  }
}

Now you are ready to deploy the frontend:

Step 3 - Deploy the backend with cloud run

Cloud run expects the container service to be exposed on port 8080, this cannot be changed. So we need to find a way to do this, luckily the wasp team enabled us to overwrite the Dockerfile server entry point and add any steps ! See the doc --> https://wasp-lang.dev/docs/advanced/deployment/overview#customizing-the-dockerfile

Create a Dockerfile at ./app/Dockerfile

ENV PORT=8080
EXPOSE 8080

ENTRYPOINT ["npm", "run", "start-production"]

Now you need to do a wasp build again with this setup to update the Dockerfile under .wasp/build/Dockerfile. After the wasp build command, you can check that the new Dockerfile has indeed your added steps in it.

Build the image and push it to Artifact Registry. I will use the Docker Artifact Registry I created in step 1 but adapt it to the one you created:

docker build -t backend-image -f Dockerfile .
# Adapt name and region to the one created earlier
docker tag backend-image europe-west1-docker.pkg.dev/{FILL_YOUR_FULL__GCP_PROJECT_NAME}/backend/backend-image
docker push europe-west1-docker.pkg.dev/{FILL_YOUR_FULL__GCP_PROJECT_NAME}/backend/backend-image

Once the image is successfully uploaded to artifact registry, you can deploy your backlend with:

          gcloud run deploy backend \
            --image europe-west1-docker.pkg.dev/{FILL_YOUR_FULL__GCP_PROJECT_NAME}/backend/backend-image \
            --project {FILL_YOUR_FULL__GCP_PROJECT_NAME} \
            --platform managed \
            --region europe-west1 \
            --max-instances 2 \ # This limit auto scaling, if you have a lot of traffic you can increase this
            --allow-unauthenticated \
            --set-env-vars "DATABASE_URL=fake_database_url" \
            --set-env-vars "STRIPE_KEY=fake_stripe_key" \
            --set-env-vars "GOOGLE_CLIENT_ID=fake_google_client_id" \
            --set-env-vars "GOOGLE_CLIENT_SECRET=fake_google_client_secret" \
            --set-env-vars "HOBBY_SUBSCRIPTION_PRICE_ID=fake_hobby_subscription_price_id" \
            --set-env-vars "PRO_SUBSCRIPTION_PRICE_ID=fake_pro_subscription_price_id" \
            --set-env-vars "SENDGRID_API_KEY=fake_sendgrid_api_key" \
            --set-env-vars "STRIPE_WEBHOOK_SECRET=fake_stripe_webhook_secret" \
            --set-env-vars "WASP_SERVER_URL=fake_wasp_server_url" \ # <--- You don't know it for now, but change it after the first deployment to the real one
            --set-env-vars "WASP_WEB_CLIENT_URL=fake_wasp_web_client_url" \ # <--- Use the value you got at the end of step 2 that looks like https://{FILL_YOUR_FULL__GCP_PROJECT_NAME}.web.app
            --set-env-vars "ADMIN_EMAILS=fake_admin_emails" \
            --set-env-vars "JWT_SECRET=fake_jwt_secret"

You will receive a made up backend URL, for example https://backend-feiufeaoa-ew.a.run.app

Step 4 - Link the frontend and backend

You now have all the elements, you just need to piece it together:

You can go back to your frontend under https://{FILL_YOUR_FULL__GCP_PROJECT_NAME}.web.app and check that everything works.

The backend URL is a bit ugly, additionally it is not under the same domain so you might run under CORS issues. Firebase offers automatic redirect to the cloud run backend directly from your website, I still need to crack that part !

TomDarmon commented 2 weeks ago

Optional, but if you need to see every steps as one big script I've made github actions for my CD. This might give a global overview of the steps to follow:

backend-cd.yml

name: Deploy to Cloud Run on merge

on:
  push:
    branches:
      - main

jobs:
  build_and_deploy_backend:
    runs-on: ubuntu-latest

    env:
      # Env variables
      GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
      WASP_SERVER_URL_DEV: ${{ vars.WASP_SERVER_URL_DEV }}
      WASP_WEB_CLIENT_URL_DEV: ${{ vars.WASP_WEB_CLIENT_URL_DEV }}
      ADMIN_EMAILS_DEV: ${{ vars.ADMIN_EMAILS_DEV }}
      # Secrets
      DATABASE_URL_DEV: ${{ secrets.DATABASE_URL_DEV }}
      SERVICE_ACCOUNT: ${{ secrets.SERVICE_ACCOUNT }}
      GOOGLE_CLIENT_ID_DEV: ${{ secrets.GOOGLE_CLIENT_ID_DEV }}
      GOOGLE_CLIENT_SECRET_DEV: ${{ secrets.GOOGLE_CLIENT_SECRET_DEV }}
      HOBBY_SUBSCRIPTION_PRICE_ID_DEV: ${{ secrets.HOBBY_SUBSCRIPTION_PRICE_ID_DEV }}
      PRO_SUBSCRIPTION_PRICE_ID_DEV: ${{ secrets.PRO_SUBSCRIPTION_PRICE_ID_DEV }}
      SENDGRID_API_KEY_DEV: ${{ secrets.SENDGRID_API_KEY_DEV }}
      STRIPE_KEY_DEV: ${{ secrets.STRIPE_KEY_DEV }}
      STRIPE_WEBHOOK_SECRET_DEV: ${{ secrets.STRIPE_WEBHOOK_SECRET_DEV }}
      JWT_SECRET_DEV: ${{ secrets.JWT_SECRET}}

    steps:
      - uses: actions/checkout@v4

      - name: Build Docker Image
        run: |
          curl -sSL https://get.wasp-lang.dev/installer.sh | sh
          cd app
          wasp build
          cd .wasp/build
          docker build -t backend-image -f Dockerfile .
          docker tag backend-image europe-west1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/backend/backend-image:${{ github.sha }}

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v1
        with:
          credentials_json: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Configure Docker
        run: |
          gcloud auth configure-docker europe-west1-docker.pkg.dev

      - name: Push Docker Image
        run: |
          docker push europe-west1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/backend/backend-image:${{ github.sha }}

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy backend \
            --image europe-west1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/backend/backend-image:${{ github.sha }} \
            --project ${{ env.GCP_PROJECT_ID }} \
            --platform managed \
            --region europe-west1 \
            --max-instances 2 \
            --min-instances 0 \
            --allow-unauthenticated \
            --set-env-vars "DATABASE_URL=${{ env.DATABASE_URL_DEV }}" \
            --set-env-vars "STRIPE_KEY=${{ env.STRIPE_KEY_DEV }}" \
            --set-env-vars "GOOGLE_CLIENT_ID=${{ env.GOOGLE_CLIENT_ID_DEV }}" \
            --set-env-vars "GOOGLE_CLIENT_SECRET=${{ env.GOOGLE_CLIENT_SECRET_DEV }}" \
            --set-env-vars "HOBBY_SUBSCRIPTION_PRICE_ID=${{ env.HOBBY_SUBSCRIPTION_PRICE_ID_DEV }}" \
            --set-env-vars "PRO_SUBSCRIPTION_PRICE_ID=${{ env.PRO_SUBSCRIPTION_PRICE_ID_DEV }}" \
            --set-env-vars "SENDGRID_API_KEY=${{ env.SENDGRID_API_KEY_DEV }}" \
            --set-env-vars "STRIPE_WEBHOOK_SECRET=${{ env.STRIPE_WEBHOOK_SECRET_DEV }}" \
            --set-env-vars "WASP_SERVER_URL=${{ env.WASP_SERVER_URL_DEV }}" \
            --set-env-vars "WASP_WEB_CLIENT_URL=${{ env.WASP_WEB_CLIENT_URL_DEV }}" \
            --set-env-vars "ADMIN_EMAILS=${{ env.ADMIN_EMAILS_DEV }}" \
            --set-env-vars "JWT_SECRET=${{ env.JWT_SECRET_DEV }}"

frontend-cd.yml

name: Deploy to Firebase Hosting on merge
on:
  push:
    branches:
      - main

jobs:
  build_and_deploy_frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          curl -sSL https://get.wasp-lang.dev/installer.sh | sh
          cd app
          wasp build
          cd .wasp/build/web-app
          npm install
          REACT_APP_API_URL=${{ vars.WASP_SERVER_URL_DEV }}
          npm run build
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: ${{ secrets.GITHUB_TOKEN }}
          firebaseServiceAccount: ${{ secrets.SERVICE_ACCOUNT }}
          channelId: live
          projectId: ${{ vars.GCP_PROJECT_ID }}
Martinsos commented 2 weeks ago

This is awesome, thanks @TomDarmon !

We can use this as a good foundation for writing the docs for deploying to GCP.

CI action is also quite useful!

Martinsos commented 2 weeks ago

The backend URL is a bit ugly, additionally it is not under the same domain so you might run under CORS issues. Firebase offers automatic redirect to the cloud run backend directly from your website, I still need to crack that part !

While Firebase does offer a way to rewrite routes to send all requests to e.g. example.com/api towards the server, we concluded that better option is, if we do want to have server on the same domain as the client, to go with a subdomain instead (e.g. api.example.com) as it provides multiple benefits and is easier to set up (DNS configuration + a bit of config on the GCP side).

Martinsos commented 2 weeks ago

Related convo on the Discord: https://discord.com/channels/686873244791210014/1249887793082138758/1249887793082138758