I developed an application that I used to deploy on Vercel. But, to deliver the best image based on the user’s device, I decided to add runtime image processing, which makes it impossible for me to deploy on edge functions. So I tried on serverless function but cold start is really really slow. Sometimes, I would experience a 5-second delay in loading my page even though I didn’t have a heavy resource load. This was mainly due to the fact that I didn’t have enough traffic on my website to avoid cold starts.
The solution I found was to dockerize my application and deploy it on my own server.
I won’t explain what Docker is since there are numerous excellent tutorials available on the web. However, to dockerize an application, the first step is to create a Dockerfile where we specify the necessary instructions. Personally, I use Astro, so here’s an example of its content:
FROM node:18.16.0 AS runtime
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV HOST=0.0.0.0
ENV PORT=8888
EXPOSE 8888
CMD node ./dist/server/entry.mjs
And that’s not all.
My application requires some environment variables, so let’s add one named DB_URL. The first thing to do is to declare that we will receive an ARG from outside:
ARG DB_URL
Then, we pass this argument as an environment variable:
ENV DB_URL=${DB_URL}
Now, let’s talk about the deployment workflow.
The deployment is done through a Github Action Workflow named deploy
. I trigger this workflow each time I push to the master branch, which performs the following steps:
- Get the previous version and increment it.
- Build and push the Docker image.
- Create a Github tag with the version and push it.
- Deploy the Docker image to my server.
Instead of retrieving the latest image name from my registry, I push the version as a git tag. This way, I only need to fetch the latest tag, which is much easier and more efficient. Furthermore, I know the content each docker image thanks to that.
The git command to achieve this is straightforward:
LATEST_TAG=git describe --abbrev=0 --tags
And now let’s increment it. But before doing it, let’s see how is structured my tag.
As I push a new version each time I push on master I decided not to use semver but just have vVersionNumber
.
To extract the number from the tag, I simply remove the leading ‘v’:
LATEST_VERSION=${LATEST_TAG#v}
Then, the new version is calculated by incrementing the latest version:
NEW_VERSION=$(($LATEST_VERSION+1))
Whole version step
- name: Get new version
id: newVersion
run: |
PREVIOUS_VERSION=$(git describe --abbrev=0 --tags)
echo "Previous Version: $PREVIOUS_VERSION"
LATEST_VERSION=${LATEST_TAG#v}
NEW_VERSION=$(($LATEST_VERSION+1))
echo "New Version: $NEW_VERSION"
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
Now it’s time to build the image and push it on the registry.
I use Docker Hub as my registry, but you can use the one you prefer.
To log in to Docker Hub, I utilize the docker/login-action@v2
Github Action and retrieve my credentials from Github Secrets:
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
I then set up Docker using docker/setup-buildx-action@v2
:
- name: Set up Docker
uses: docker/setup-buildx-action@v2
And now let’s build our image thanks to docker/build-push-action@v4
:
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
build-args: |
"DB_URL=${{ secrets.DB_URL }}"
push: true
tags: astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
Now that the docker image is created and pushed, I can create the git tag and push it for future releases.
The first step is to set up the git config. Personally, I’ve chosen to use a fake “GitHub Actions” user for this purpose:
- name: Setup git config
run: |
git config --local user.name "GitHub Action"
git config --local user.email "githubAction@users.noreply.github.com"
And now I can easily create and push the tag.
- name: Create and push git tag
run: |
git tag v${{ steps.newVersion.outputs.NEW_VERSION }}
git push origin v${{ steps.newVersion.outputs.NEW_VERSION }}
We are nearing the end of this workflow. Now it’s time to deploy the Docker image to the server.
My server is already configured with a Docker registry, eliminating the need for logging in during the GitHub Actions workflow.
To accomplish this, I utilize the appleboy/ssh-action@v0.1.10
action, which connects to the server and performs the following steps:
- Pulls the latest image.
- Stops the existing container (if any).
- Deletes the previous container (if any).
- Runs a new container with the freshly pulled image.
- name: Deploy docker image to server
uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
docker stop web || true
docker rm web || true
docker run -d -p 8888:8888 --restart unless-stopped --name web astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
As I mentioned previously, while my workflow may not be perfect, it is perfectly suited for my needs. With this workflow, I have eliminated cold starts and achieved an incredibly fast First Contentful Paint. It has been a significant improvement for my website.
In the future, I may consider using Ansible to enhance the process and make it more robust. Ansible’s configuration management capabilities would allow me to automate and standardize the deployment process further. This would provide better error recovery and ensure consistency across deployments.
Overall, I’m satisfied with the current workflow, but I’m always open to exploring new tools and techniques to optimize my development and deployment processes.
If you want to see the whole Github Action Workflow, you can see my gist .
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.