Deploying an Advanced CMS with Astro and Strapi Using Portainer and PostgreSQL

2024-12-02 / 6 min read
Last updated: 2024-12-02

Blog


I decided to migrate my deployments from native environments to containerization. I had numerous projects deployed, and the new ones had conflicting requirements. Additionally, backup and recovery became much simpler when the application is defined in a single docker-compose file.

In this guide, I'll explain how to deploy Astro and Strapi using PostgreSQL to create your new advanced CMS site—all in one comprehensive tutorial. I spent an embarrassingly long time figuring this out, but it's not entirely my fault (as I'll explain below).

The stack:

  • Astro Server Side Rendered (SSR)
  • Strapi 4
  • Postgress 12
  • Nginx
  • Portainer using GitOps

Create a repository to host your code

Choose any repository hosting service you'd like. I host mine on my own GitLab server. This will enable us to automatically deploy the application when a new push is made using Portainer's GitOps capabilities.

Folder Structure:

  • Frontend (Astro): Root directory (/)
  • Backend (Strapi): /server

Note: In retrospect, I would have placed the Astro frontend in its own folder as well.

Declare Dockerfiles for Both Server and Client

Astro Dockerfile

This Dockerfile is located in the root directory and is responsible for creating the Astro Docker image.

FROM node:18-alpine AS base
WORKDIR /app

COPY . .

RUN yarn install
RUN yarn build

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs

Strapi Dockerfile

This Dockerfile is located in the server directory and is responsible for creating the Strapi Docker image.

Notes:

  • strapi-db-data-dbdump is not needed for the first deployment. It's useful if you're migrating the project and want to restore the database from a SQL dump file.
  • strapi-uploads: Separating Strapi's uploads directory ensures uploaded content remains safe during redeployments.
# Build stage
FROM node:18-alpine
# Installing libvips-dev for sharp Compatibility
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev

ARG NODE_ENV=development
ENV NODE_ENV=${NODE_ENV}

WORKDIR /opt/app

# Copy package.json and yarn.lock
COPY package.json yarn.lock ./

# Clean Yarn cache and install dependencies
RUN yarn cache clean && yarn install --frozen-lockfile --verbose

COPY . .

# Build admin panel
RUN yarn build

# Expose port and run the app
EXPOSE 1337

CMD ["yarn", "start"]

Create the Docker Compose File

Create a docker-compose.yml file in the root directory:

services:
  strapiDB:
    restart: unless-stopped
    image: postgres:12.0-alpine
    platform: linux/amd64
    environment:
      POSTGRES_USER: ${DATABASE_USERNAME}
      POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
      POSTGRES_DB: ${DATABASE_NAME}
    volumes:
      - strapi-db-data:/var/lib/postgresql/data/
      - strapi-db-data-dbdump:/docker-entrypoint-initdb.d
    ports:
      - "5432:5432"
    networks:
      - luke-iremadze-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME} -d ${DATABASE_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5

  strapiAdminer:
    image: adminer
    restart: unless-stopped
    ports:
      - "9090:8080"
    environment:
      - ADMINER_DEFAULT_SERVER=strapiDB
    networks:
      - luke-iremadze-network
    depends_on:
      - strapiDB

  backend-strapi:
    build:
      context: ./server
      dockerfile: Dockerfile
    pull_policy: build
    depends_on:
      strapiDB:
        condition: service_healthy
    restart: unless-stopped
    environment:
      DATABASE_CLIENT: ${DATABASE_CLIENT}
      DATABASE_HOST: ${DATABASE_HOST}
      DATABASE_PORT: ${DATABASE_PORT}
      DATABASE_NAME: ${DATABASE_NAME}
      DATABASE_USERNAME: ${DATABASE_USERNAME}
      DATABASE_PASSWORD: ${DATABASE_PASSWORD}
      JWT_SECRET: ${JWT_SECRET}
      ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
      APP_KEYS: ${APP_KEYS}
      NODE_ENV: ${NODE_ENV}
      API_TOKEN_SALT: ${API_TOKEN_SALT}
      TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
      PUBLIC_URL: ${PUBLIC_URL}
    volumes:
      - strapi-data:/opt/app
      - strapi-uploads:/opt/app/public/uploads
    ports:
      - "1337:1337"
    networks:
      - luke-iremadze-network

  frontend:
    build:
      context: .       # Path to Astro frontend project
      dockerfile: Dockerfile
    pull_policy: build
    volumes:
      - astro-static:/app/dist
    ports:
      - "4321:4321"
    networks:
      - luke-iremadze-network

  webserver:
    depends_on:
      - frontend
      - backend-strapi
    image: nginx:stable-alpine
    restart: unless-stopped
    ports:
      - "8295:80"
    volumes:
      - /srv/${PROJECT_NAME}/nginx/default.conf:/etc/nginx/conf.d/default.conf
      - astro-static:/usr/share/nginx/html
      - strapi-data:/opt/app
      - strapi-uploads:/opt/app/public/uploads
    networks:
      - luke-iremadze-network

volumes:
  strapi-db-data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /srv/${PROJECT_NAME}/pg-data
  strapi-db-data-dbdump:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /srv/${PROJECT_NAME}/dump
  strapi-data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /srv/${PROJECT_NAME}/strapi
  strapi-uploads:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /srv/${PROJECT_NAME}/uploads
  astro-static: # Defined the volume for static assets
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /srv/${PROJECT_NAME}/public_html

networks:
  luke-iremadze-network:
    driver: bridge

Notes:

  • Replace ${PROJECT_NAME} with your actual project name.
  • Ensure all environment variables are defined in an .env file or within your deployment platform.
  • Pre-create the folders for the volumes to mount

Nginx Default Configuration

Create the default.conf file in /srv/${PROJECT_NAME}/nginx/default.conf.

# Proxy for Strapi (api.iremadze.com)
server {
    listen 80;
    server_name api.iremadze.com;

    location / {
        proxy_pass http://backend-strapi:1337; # Strapi backend service
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_pass_request_headers on;
    }
}

# Proxy for Astro (luke.iremadze.com)
server {
    listen 80;

    server_name luke.iremadze.com;

    location / {
        proxy_pass http://frontend:4321; # Astro frontend service
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Note: Replace api.example.com and www.example.com with your actual domain names.

Add the Repository to Portainer

Step 1: Create a new stack in Portainer and add the repository address. Portainer 1.png

Step 2: Select the appropriate settings to enable auto-updates. Portainer 2.png

Step 3: Declare the environment variables. Screenshot 2024-12-02 at 08.40.21.png

Environment Variables:

DATABASE_USERNAME=strapi-db-user
DATABASE_PASSWORD=PASSWORD
DATABASE_NAME=strapi-db
DATABASE_PORT=5432
DATABASE_CLIENT=postgres
JWT_SECRET=SDFGWERG@
ADMIN_JWT_SECRET=@#F@#FE@RF@R
APP_KEYS=DFGSDFG,SDFGDFG,SDFGSDFG,QWREFGF==
NODE_ENV=production
PROJECT_NAME=lukes-landing
DATABASE_HOST=strapiDB
API_TOKEN_SALT=1242352FD@
TRANSFER_TOKEN_SALT=SFGSG#G#
PUBLIC_URL=https://api.iremadze.com/

Errors I Faced and My Resolutions

The db.config.connection is undefined error

I spent an embarrassing amount of time troubleshooting the error:

TypeError: Cannot destructure property 'client' of 'db.config.connection' as it is undefined

Cause: This issue is related to TypeScript and its behavior within Docker.

Resolution: Remove the noEmit rule from your tsconfig.json. Here's the configuration I used, thanks to tirthshah574:

{
  "extends": "@strapi/typescript-utils/tsconfigs/server",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": ".",
    "lib": ["esnext", "dom"],
    "skipLibCheck": true, // Skip type checking of declaration files
    "strict": false, // Disable strict type checking
    "allowJs": true, // Allow JavaScript files to be compiled
    "checkJs": false, // Disable checking JavaScript files
    "noImplicitAny": false,
    "noImplicitThis": false
  },
  "include": [
    "./",
    "./**/*.ts",
    "./**/*.js",
    "src/**/*.json",
  ],
  "exclude": [
    "node_modules/",
    "build/",
    "dist/",
    ".cache/",
    ".tmp/",
    "src/admin/",
    "**/*.test.ts",
    "src/plugins/**",
    "types/generated/**"
  ]
}

Updates Don't Reflect After Redeployment

After updating the settings, I noticed that changes weren't reflecting upon redeployment.

Cause: The new image deployment wasn't overwriting existing files in the volumes, causing the application to use older versions.

Resolution: Manually remove the strapi and public_html folders from the project directory on the server before redeploying via Portainer. This forces the containers to use the updated images.

Final thoughts

By following this guide, you should have a fully functional CMS setup using Astro and Strapi, managed through Docker Compose and Portainer. This approach simplifies dependency management, resolves conflicts, and streamlines backup and recovery processes.

I will be on the lookout to automate the contents of the project being deleted and recreated to be fully automatic, but in the mean time I'll make due with this.

Feel free to share your experiences or ask questions in the comments below!