Dalius's blog

Saturday, January 25, 2025

Kamal & Fastify & Nodemailer

Let’s continue my Kamal exploration. Now I have tried to migrate fastify app to Kamal. I have simple portfolio app with contact form so it is not static site anymore and I have to pass some env variables because of contact form.

Dockerfile is quite simple:

FROM node:22-alpine AS base
RUN corepack enable yarn
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN yarn workspaces focus --production

FROM base AS production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

I am not going to show here how fastify works but here are important bits for Kamal:

// We need /up to return 200 OK
fastify.get('/up', (request, reply) => {
  reply.type('text/html').send(`<p>OK</p>`);
});

// Important bit here to set host to 0.0.0.0 because we will use this in Docker
await fastify.listen({ port: 3000, host: '0.0.0.0' });

Here is excerpt from deploy.yml that is important as well. I use port 3000 and I want site to be accessible via host and www subdomain:

proxy:
  ssl: true
  hosts:
    - ffff.lt
    - www.ffff.lt
  app_port: 3000

Now if we want ffff.lt to be redirected to www.ffff.lt we can do it like this (that’s not necessary proper way, but one way how to do it):

const redirectNonWwwToWww = (req, reply, done) => {
  if (req.hostname === 'ffff.lt') {
    const wwwUrl = `http://www.${req.hostname}${req.url}`;
    reply.redirect(wwwUrl);
  } else {
    done();
  }
};

fastify.addHook('preHandler', redirectNonWwwToWww);

Nodemailer & Hetzner

Now I use gmail with nodemailer to send mails from contact form to specific e-mail address. This is how it should work (some details here https://www.nodemailer.com/usage/using-gmail/) and it works for me:

const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: 'your-email@gmail.com', // Your Gmail address
    pass: 'your-app-password'     // App password or email password
  }
});

However this is not working on Hetzner. I have tried to use that and got timeout. Googling suggested several options what might be problem:

  • Ipv6 problem. I have even tried forcing ipv4 in dns resolution. No success.

  • Nodemailer not working properly in Docker. I have tested Docker hypothesis locally:

    docker build -t ffff .
    
    docker run -p 3000:3000 -e MAIL_PASS=$MAIL_PASS ffff
  • Port blocking.

It was working properly from Docker, forcing ipv4 has not helped. So it was clear that problem is in Hetzner. Eventually I have found out that Hetzner blocks some ports https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server

The same article states that port 587 is OK so solution was:

let transporter = nodemailer.createTransport({
  host: 'smtp.gmail.com',
  port: 587,
  secure: false,
  requireTLS: true,
  auth: {
    user: process.env.MAIL_USER,
    pass: process.env.MAIL_PASS,
  },
});

Overall Kamal works for this service without problems as well.