Create a blog
A Spark In The Dark
How do you create a blog? Well there are a lot of SaaS products in the market that will cater for that particular craving, like simply.com, wix, more. But what if you want to host it yourself, pick a template you just feel more comfortable with, and perhaps you even luster the challenge to like build the stuff?
I have been tinkering with a system for quite a while now and then the other day Ajs floated this idea: why not document the process [ed. like all the cool guys] ?
So - that is what this post is about: setup a blog and host it yourself.
Research
honeybadger.io using Jekyll is a very well written post on how to setup a
Jekyll based documentation system with great links to writing style guides and more.
evilmartians.com - I am
tempted to add “as usual” - outdid themselves again with a great piece on building software; this one is focused on building an API and doing so coming from the documentation side of things first, and it is a marvel! While I’m at it - don’t forget to give bump.sh a look see!
I was looking for something a little different though and so I started looking up the templates that comes with my subscription to tailwindui.com - sadly it’s all Next.js and I’m a Ruby guy.
It’s not like I don’t know how to read JavaScript but certainly am quite rusty at it! Anyways, I found it fairly easy to get started with.
Settled for syntax for my first test. npm install
the README stated - but carefully left out
and when I tried my luck with npm run dev
reality was swiftly dealing me the deciding blow
I knew I’d been slagging on the “keep your workbench clean AND fresh” - but there was help npm install -g npm
.
Before, however, I embarked on that journey I did something else needing done: asdf list all nodejs
and once realizing how much I was getting behind - I hurried to asdf install nodejs 22.7.0
closely followed by asdf local nodejs 22.7.0
It had a lot of what I was looking for but cut more towards documenting the features of the product than the process.
My second test used the spotlight template and I was very pleased with the result - so there you have it.
Implementation
I use Kamal to deploy Mortimer so I knew that was top of the list. We like the offerings from Hetzner and so I knew that would have to be part of the equation as well.
I will tell it straight as it is: I googled “deploy next with Kamal” and literally my
first hit was logsnag - sharing is wonderful, so thank you Shayan!
You can follow along here or jump to logsnag - the “story” is almost 1:1 - with me only transposing minor differences from a standard Next.js setup to the spotlight template.
1. Install Kamal
This one is easy - gem install kamal
- and you’re done! I'd recommend you to check out the Kamal documentation. Having kamal installed already I was quickly moving forward.
2. Create a new project
This one was even easier - considering that I had already downloaded spotlight
All I had to do was
# cd ~/src
# mv ~/Downloads/spotlight .
# open spotlight.zip
# cd tailwindui-spotlight
# npm install
# cp .env.example .env.local
Following the README.md I edited the .env.local
file.
3. Setting up health checks
I am not the TypeScript guy - but I know that it’s a good thing to have health checks, in fact Kamal requires it to be able to tell whether your app is booting correctly (and it has saved me on multiple occasions so I'm a fanboy).
Anyways, I mkdir app/up && touch app/up/route.js
and added the following code to the route.js file:
export async function GET(req) {
return new Response('Ok', { status: 200 });
}
4. Retrofitting a Dockerfile
First task was to add a single line to the next.config.mjs file output: 'standalone'
import rehypePrism from '@mapbox/rehype-prism'
import nextMDX from '@next/mdx'
import remarkGfm from 'remark-gfm'
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'],
experimental: {
outputFileTracingIncludes: {
'/articles/*': ['./src/app/articles/**/*.mdx'],
},
},
output: 'standalone'
}
const withMDX = nextMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrism],
},
})
export default withMDX(nextConfig)
Second, I added a .dockerignore
file:
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.gitignore
And finally, the Dockerfile:
FROM node:20-alpine AS base
# Disabling Telemetry
ENV NEXT_TELEMETRY_DISABLED 1
RUN apk add --no-cache libc6-compat curl
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
5. Building the Docker image
With all the pieces in place I was ready to build the Docker image, (and test it locally):
docker build -t mortimer-blog .
docker run -p 3000:3000 mortimer-blog
6. Preparing a VM on Hetzner
I mentioned earlier that we like Hetzner and so I went ahead and created a new VM (when Kamal 2.0 breaks featuring managing multiple apps I guess I’ll have to revisit this part - but that will be a post for another day).
Creating a new VM is easy - I went with
- Helsinki
- Ubuntu 24.04 image
- shared vCPU on Arm64
- 4GB of RAM
- public IPv4
We manage a few servers on Hetzner already so adding my SSH key and setting up proper firewalls was as easy as tapping the right boxes.
7. Setting up Kamal
This one is in fact important - if you don't get it right you'll be scratching your head for a while.
First there is the kamal init
command - it will ask you a few questions and then create a kamal.yml
file for you.
( But before you get to enjoy that - you will have to add ruby 3.2.2
to the .tool-versions
file; if it's not in the root of your project you can add it with echo "ruby 3.2.2" >> .tool-versions
)
Then edit the config/deploy.yml
file to look like this:
# config/deploy.yml
# Name of your application. Used to uniquely configure containers.
service: app_name
# Name of the container image.
image: user_name/app_name
# where to build the image
builder:
remote:
arch: arm64
host: ssh://root@1.2.3.4
# Deploy to these servers.
servers:
- 1.2.3.4
# Credentials for your image host.
registry:
username:
- KAMAL_REGISTRY_USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
port: 3000
- and do not forget to add an .env
file to the root of your project with the following content:
# .env
KAMAL_REGISTRY_USERNAME=your-docker-hub-username
KAMAL_REGISTRY_PASSWORD=your-docker-hub-password
8. Deploying
If you stuck with me this far you are ready to deploy your app.
After all the setting up and configuring you can now run kamal setup
and watch the magic happen.
Kamal will prepare the server (installing Docker if not there), build your Docker image, push it to your registry and then you can kamal deploy
to deploy it to your server!
Should you end up with a Finished in 103.0 seconds
you are good to go - and you can now visit your site at the IP address of your server.
9. A custom domain
While http://1.2.3.4 is nice and all - I wanted to use my domain. I use Cloudflare for all my DNS needs and so I added an A record pointing to the IP address of my server.
Because we run a staging server as well as the production server for Mortimer I added a CNAME record for blog.
Waiting for the DNS to propagate I was able to visit my site at blog.mortimer.pro - or so I thought!
What I did in fact get was a 521 -
Then I remembered that I had to tell Traefik to listen for the https trafik - and so I changed the config/deploy.yml
file:
# Name of your application. Used to uniquely configure containers.
service: app-name
# Name of the container image.
image: user_name/app-name
# where to build the image
builder:
remote:
arch: arm64
host: ssh://root@1.2.3.4
# Deploy to these servers.
servers:
web:
hosts:
- 1.2.3.4
labels:
traefik.enable: true
traefik.http.routers.mortimer.entrypoints: websecure
traefik.http.routers.mortimer.rule: Host(`blog.mortimer.pro`)
traefik.http.routers.mortimer.tls.certresolver: letsencrypt
traefik.http.routers.mortimer.tls.domains[0].main: blog.mortimer.pro
traefik.http.routers.mortimer.tls.domains[0].sans: blog.mortimer.pro
traefik:
options:
publish:
- "443:443"
volume:
- "/root/letsencrypt/acme.json:/letsencrypt/acme.json"
args:
api.dashboard: true
log.level: INFO
accesslog.format: json
accesslog.filters.statusCodes: "400-599"
accesslog.filters.retryAttempts: true
accesslog.filters.minDuration: 101ms
entryPoints.web.address: ":80"
entryPoints.websecure.address: ":443"
entryPoints.web.http.redirections.entryPoint.to: websecure
entryPoints.web.http.redirections.entryPoint.scheme: https
entryPoints.web.http.redirections.entrypoint.permanent: true
certificatesResolvers.letsencrypt.acme.email: "walther@alco.dk"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesResolvers.letsencrypt.acme.httpchallenge: true
certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
# Credentials for your image host.
registry:
username:
- KAMAL_REGISTRY_USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
port: 3000
10. Troubleshooting
I started pooking around and found that the ACME resolver "letsencrypt" is skipped from the resolvers list because:
kamal traefik reboot
kamal traefik logs -f
...
level=error msg="The ACME resolver \"letsencrypt\" is skipped from the resolvers list because: unable to get ACME account: permissions 755 for /letsencrypt/acme.json are too open, please use 600"
...
I changed the permissions on the acme.json file and rebooted Traefik:
rm -r /root/letsencrypt/acme.json
touch /root/letsencrypt/acme.json
chmod 600 /root/letsencrypt/acme.json
kamal traefik reboot
And now I was able to visit my site at blog.mortimer.pro - and so can you!
Oh, and btw: do not start tearing apart your LAN, shop for a new router, or melting down if you cannot find anything on the Internet at that URL, 'cause this was just an exercise and there is no blog.mortimer.pro; but Mortimer is for real should an easy Time and Attendance SaaS solution come in handy ;)