Strapi: From Dev to Production
Introduction
In this post, we will go over starting a Strapi project from development and then moving Strapi into a production environment. For brevity of this post, we have omitted some commonly used best practices in the DevOps sector. The main intent is to understand the basic workflow, and later we can introduce DevOps practices.
The diagram above is our basic layout of the environment. The user workstation is a MacBook Air M1 running VSCode with the Remote — SSH integration. The “Dev Box” is a Gigabyte NUC running Debian with nerdctl installed. Finally, we have a Production Server with 2 Raspberry Pi 4s running K3s joined to create a cluster.
We can already recognize that there are some bad practices at play here. First, the Dev Box and Production server utilize different technologies. Dev Box only runs nerdctl for container work, and the production server runs Kubernetes. The mismatch may introduce errors in the deployment process, but this will be addressed in later posts. Long story short, due to global semiconductor issues, it was difficult to source another set of Raspberry Pi 4s to build the development environment. The user workstation (Mac M1) had problems with Docker/nerdctl/k3s and required Lima to be installed, cluttering the system on the workstation. A workaround through these limitations was developed and used in this post. And yes, lack of DevOps all around, but the issues found here are often what I encounter when working with companies.
Development Setup
We will use VS Code on the Mac M1 with the Remote-SSH plugin in our development setup. The Remote-SSH plugin allows the IDE to connect to the development box to perform our work. This eliminates the need to install various tools on the developer’s laptop.
On the development box, ensure that nerdctl is installed. A previous post goes over some ins and outs of nerdctl. Also, install npm on the development box. If you use an x64-based system, this step can be omitted and installed on the local developer workstation, which is the preferred method.
You can refer to the GitHub repo to get the scripts used in this setup. Create the following directories with the backend and database:
npx create-strapi-app@latest strapi
Use the default values except for Database type and select PostgreSQL. Once the setup is complete, there should be a strapi directory now.
Database Setup
Create a directory for the database and download the startDB script from GitHub.
$ mkdir database && cd database
$ wget https://raw.githubusercontent.com/salimwp/strapi-prod-tut/main/database/startdb.sh
$ sudo ./startDB.sh
A container instance of PostgreSQL should now come up. We will need the IP address of the PostgreSQL container. This can be acquired by running the following command:
$ sudo nerdctl inspect -f ‘{{ .NetworkSettings.IPAddress }}’ strapiDB
10.4.0.157
Strapi Development Setup
In the Strapi directory, run the following commands:
$ export DATABASE_HOST=10.4.0.157
$ yarn develop
Strapi will take the environmental variables and use them first. If the environment variables are not present, then the default values used in the npx creation step will be used.
When the server is up and running, you may be sent to the initial webpage to create an account. If the webpage doesn’t automatically load up, then go to http://localhost:1337/admin. Users utilizing the VSCode Remote-SSH plugin will have entries created to automatically forward ports from their Desktop to the Dev Box server. This avoids the headaches of manually setting up port forwards between the desktop and Dev Box server.
For this post, we will model a simple catalogue of businesses. The following is the basic table we will use in this setup.
Create a new collection type called Store
Create a ‘Text’ field collection
Enter ‘Name’ under Name
Click on ‘Add another field’ and repeat the above tasks to create two additional Text fields for ‘Description’ and ‘URL.’
Once the tasks are completed, click on ‘Finish,’ which brings up the following webpage.
Click ‘Save’ in the top right corner. The server will write out the routes, controllers and other files to the backend server’s filesystem.
Let’s now say we want to make this example interesting. A store on its own is boring. One thing that can be added to the store are services it provides.
Create another collection type for service with a ‘Name’ and ‘Description’ using the Text field type. Click ‘Save’ to render out the necessary files for the backend to route the API calls.
When the server comes back up, we will need to create the mapping from Store to Service. Go to the ‘Store’ collection type and click on ‘Add another field to this collection type.’
Select ‘Relation’
Create a ‘has many’ relationship between Store to Services and click on ‘Finish.’
Strapi Staging Test
The typical application development process is promoting an application from desktop to dev, then staging and finally production. Due to limited resources, we will also use the Dev Box as staging.
Let’s kill the development mode of Strapi and go to production mode. On the console running Strapi, press CTRL-C to kill the server. Then restart the server with the new CLI command:
$ NODE_ENV=production yarn start
Notice that this time, the NODE_ENV is set to production, and ‘yarn start’ is being used instead of ‘yarn develop.’
When the browser opens to http://localhost:1337, you will notice that Strapi is now operating in Production mode.
When logged into production mode and going to the ‘Content-Type Builder’ screen, the option to create a new Collection Type is no longer available. This is one key difference between development and production mode. However, you can still create new entries for the Service and Store collections through the WebUI.
Create 2 Services in Strapi. The first service can be ‘Grocery’ with ‘Food items we need.’ Click save, followed by ‘Publish’
Repeat the process again but for ‘Hardware’ and in the description ‘Household hardware items’.
Create a Store with the following:
Name: ABC Grocery
Description: Local grocery store
URL: https://abc.example.org
Service: Grocery
Click ‘Save’ and ‘Publish’
Repeat the process for two more stores.
Name: DEF Hardware
Description: Local hardware store
URL: https://def.example.org
Service: Hardware
Name: XYZ Big Store
Description: Big box store
URL: https://xyz.example.org
Service: Hardware, Grocery
Once completed, the store collections should be populated with the three stores.
Now that there is some content on the server. The next step should be retrieving that content over the REST API endpoint.
Navigate to the API Tokens screen via Setting and clicking on API Tokens
Click on the ‘Create new API Token’ button in the top right. For our tests, we will mostly focus on read operations. Enter the following data on the webpage.
Name: Read-only
Token type: Read-only
Description: Read only token
Click on ‘Save’. The next screen will show your API token. Ensure that the API token is recorded in a safe location.
On the CLI, run a curl command against http://localhost:1337/api/stores, as shown in the screenshot below.
Containerization
The next step in operationalizing Strapi is to create a container to house the Strapi application. Since Strapi is developed on NodeJS, creating a container is pretty standard.
Strapi server configuration needs to be updated to allow us to change the URL via environmental variables. Update the config/server.js to reflect the contents below and re-run the yarn build command.
module.exports = ({ env }) => ({
url: env('URL','http://localhost'),
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
});
In the strapi directory, create a Dockerfile file with the following contents.
FROM node:16ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}WORKDIR /appCOPY package.json /app
COPY yarn.lock /appRUN yarn installCOPY . .EXPOSE 1337RUN chown node.node -R /appUSER nodeCMD ["yarn","start"]
Run the following command:
$ sudo nerdctl build -t strapiportal .
When the container has been built, launch
$ export POSTGRES_IP=`sudo nerdctl inspect -f ‘{{ .NetworkSettings.IPAddress }}’ strapiDB`
$ nerdctl run — name strapiportal -p 1337:1337 -e DATABASE_HOST=$POSTGRES_IP strapiportal:latest
The container runtime has been instructed to forward port 1337 from the localhost to the container. From the browser, access the Strapi admin website through http://localhost:1337. The contents of the Store collection should contain three entries.
Kubernetes
To help with moving Strapi to production, we will use Kubernetes. First, we will put the Strapi container into a container registry. For simplicity, we will use Docker Hub. Run the following commands to tag and push the image.
$ sudo nerdctl tag strapiportal:latest elysautus/strapiportal:latest
$ sudo nerdctl push elysautus/strapiportal:latest
To speed up this post, the Kubernetes YAML manifest files can be found in the project Git repo. If you are following along and trying to run this in your setup, ensure that K3s is installed on your system.
The following changes need to be made to the Kubernetes YAML files to run the Strapi application in Kubernetes. Apply the following changes to the Kubernetes manifests.
K8s/02_ingress.yaml
Line 11: update the host to match the correct FQDN
For the pgsql.secret and strapi.secret files, copy the associate example files and populate the fields with the correct values. There is a sample CLI command that can help generate random values for the fields. When the changes have been made, run the following commands from the k8s directory:
$ $ kubectl kustomize | kubectl apply -f –
This assumes that KUBECONFIG is set to the correct k8s config file and kubectl is in the PATH.
When you deploy the Kubernetes manifest to a K8s cluster and go to the admin page, you’ll notice the admin registration page again. This is because the data from the development server hasn’t been moved over to the production server. In my opinion, this is a better approach to help separate development from production. Create an admin user as before.
Logging onto the production PostgreSQL k8s container and viewing the tables created shows the collections Services and Stores.
The Services and Stores tables show that we have the columns created in the development Strapi instance.
Three methods can be used to generate the data from development to production. This will ultimately depend on your use case, as development data is sometimes fictitious, and new data is sufficient for production. The first method is simply starting the production server without data and creating information from scratch. The second method is to copy the data from development into production, sometimes utilizing the technique known as seeding. This post will use seeding to migrate data from development to production. Most ORM frameworks tend to have a migration/seeding method integrated. For now, we will use straight SQL commands.
On the development machine, run the following commands:
$ sudo nerdctl exec -it strapiDB -- psql -U strapi -c "\copy (SELECT * FROM stores) TO STDOUT WITH CSV DELIMITER ',' HEADER " > /tmp/stores.csv$ sudo nerdctl exec -it strapiDB -- psql -U strapi -c "\copy (SELECT * FROM services) TO STDOUT WITH CSV DELIMITER ',' HEADER " > /tmp/services.csv$ sudo nerdctl exec -it strapiDB -- psql -U strapi -c "\copy (SELECT * FROM stores_services_links) TO STDOUT WITH CSV DELIMITER ',' HEADER " > /tmp/stores_services_links.csv
First, the location of the PostgreSQL server on the k8s cluster needs to be located. This can be found running the following command:
$ kubectl get po -n strapi
Here we can see the pod holding the PostgreSQL data is postgresql-84d4f646d4-xhrrb.
Now import the data:
$ cat services.csv | kubectl exec -it postgresql-84d4f646d4-xldg8 -n strapi -- psql -U strapi -c "copy services (id,name,description,created_at,updated_at,published_at,created_by_id,updated_by_id) FROM STDIN WITH DELIMITER AS ',' HEADER CSV"$ kubectl exec -it postgresql-84d4f646d4-xldg8 -n strapi -- psql -U strapistrapi=# select * from services;id | name | description | created_at | updated_at | published_at | created_by_id | updated_by_id----+----------+--------------------------+-------------------------+-------------------------+-------------------------+---------------+---------------1 | Grocery | Food items we need | 2022-07-30 14:19:58.73 | 2022-07-30 14:20:08.016 | 2022-07-30 14:20:08.012 | 1 | 12 | Hardware | Household hardware items | 2022-07-30 14:21:23.896 | 2022-07-30 14:21:25.755 | 2022-07-30 14:21:25.751 | 1 | 1(2 rows)
Now, if we go back to the Content Manager and refresh the Service screen, the UI will show the services.
Ensure that the sequence for services has been updated as well. Run the following command:
$ kubectl exec -it postgresql-84d4f646d4-xldg8 -n strapi — psql -U strapi -c ‘ALTER SEQUENCE services_id_seq RESTART WITH 3;
To verify that the database works, create a new entry with the following data.
Name: Laundry
Description: Clean clothes
Click ‘Save’ and ‘Publish’
The services should now contain three entries.
Repeat for stores and stores_services_links
$ cat stores.csv | kubectl exec -it postgresql-84d4f646d4-xldg8 -n strapi — psql -U strapi -c “copy stores (id,name,description,url,created_at,updated_at,published_at,created_by_id,updated_by_id) FROM STDIN WITH DELIMITER AS ‘,’ HEADER CSV”
COPY 3$ kubectl exec -it postgresql-84d4f646d4-xldg8 -n strapi — psql -U strapi -c ‘ALTER SEQUENCE stores_id_seq RESTART WITH 4;’
ALTER SEQUENCE$ cat stores_services_links.csv | kubectl exec -it postgresql-84d4f646d4-xldg8 -n strapi — psql -U strapi -c “copy stores_services_links (store_id,service_id) FROM STDIN WITH DELIMITER AS ‘,’ HEADER CSV”
COPY 4
Create a new store entry with the following data:
Name: CAB Cleaning
Description: Cleaning business
URL: http://cab.cleaning.co
Services: Laundry
Click on ‘Save’ and ‘Publish’
The Store collection should now have four entries.
One final check is to ensure the REST API is working and can retrieve the data from the production Strapi. With the deployment from development to production, the backend database will not have any API Tokens from the development Strapi instance. Navigate to API Token and create a new API token.
You can use the following data:
Name: read-only
Description: Read-only
Token Type: Read-only
Click on ‘Save’ and note the newly generated token.
On a terminal run:
curl -H “Authorization: Bearer $BEARER_TOKEN” https://<production-domain>/api/stores | jq .
I know this post was rather long. But now we have a playground setup to practice employing better DevOps practices to bring efficiency to this process. In later posts, we will go over integrating those best practices.