DigitalOcean to Fly.io

Hello folks,

In the pursuit of saving more and more money to run my websites, I recently switched this blog to Fly.io. A pretty neat developer-oriented hosting solution that has a fabulous free tier and bills extra as you go.

It's been out there since 2017 but only found out recently. Thanks to @Chris from Servers For Hackers!

Last time, I was very happy to announce that my hosting costs were around $5-6 a month, while now it's very close to $0.

The solution I had in mind was to convert my Ghost blogs into static websites, using Hugo. I reeaaally wanted to give it a shot because I'm sure this framework is damn cool. But, for that, I need time, which I lack [fr].

The dynamic to static conversion should happen sometime because there's no point in having a 24/7 server running for a few page views and with such a low writing activity. I'll do it for the planet.

Anyways, Fly.io pushed the simple Docker hosting even further than my previous DigitialOcean + RancherOS stack. Previously, I hosted my 2 Ghost blogs on 1 droplet and had to manage the Let's Encrypt certificates using a bit of docker container/volumes scaffolding.

With Fly.io, it's a single command flyctl certs create <hostname> along with a DNS entry addition. Done!

Fly.io works natively with Docker containers so I simplified my build/upgrade workflow. For instance, the Dockerfile of this blog currently looks like this:

ARG GHOST_VERSION
ARG ADAPTER_VERSION
FROM ghost:${GHOST_VERSION}-alpine as cloudinary
RUN apk add g++ make python3
RUN su-exec node yarn add ghost-storage-cloudinary@${ADAPTER_VERSION}

FROM ghost:${GHOST_VERSION}-alpine
ARG ADAPTER_VERSION
LABEL GHOST_VERSION=${GHOST_VERSION} \
    ADAPTER_VERSION=${ADAPTER_VERSION}
COPY --chown=node:node --from=cloudinary $GHOST_INSTALL/node_modules $GHOST_INSTALL/node_modules
COPY --chown=node:node --from=cloudinary $GHOST_INSTALL/node_modules/ghost-storage-cloudinary $GHOST_INSTALL/content/adapters/storage/ghost-storage-cloudinary
ADD --chown=node:node chroma/chroma-reloaded.tar.gz $GHOST_INSTALL/content.orig/themes/chroma-reloaded/
WORKDIR $GHOST_INSTALL

For local development and tests, I have now a single docker-compose.yaml file:

services:
  blog:
    image: blog
    build:
      context: .
      args:
        GHOST_VERSION: 5.22
        ADAPTER_VERSION: v2.2.5
    ports:
      - "2368:2368"
    volumes:
      - ./ghost.db:/var/lib/ghost/content/data/ghost.db
      - ./chroma:/var/lib/ghost/content/themes/chroma-reloaded/
      - ./config.development.json:/var/lib/ghost/config.development.json
    environment:
      DEBUG: ghost:*,ghost-config,ghost-storage-cloudinary:*
      NODE_ENV: development
      CLOUDINARY_URL: cloudinary://...

And since Ghost allows to set the config using env vars, all my production config went to my fly.toml config file:

[env]
# Default config: https://github.com/TryGhost/Ghost/tree/main/ghost/core/core/shared/config
# DEBUG = "ghost:*,ghost-config,ghost-storage-cloudinary:*"

# CLOUDINARY_URL = $ fly secrets list
# Will need to be "development from Ghost 6"
NODE_ENV = "production"
database__client = "sqlite3"
database__connection__filename = "/var/lib/ghost/content/data/ghost.db"
imageOptimization__resize = "false"
logging__level = "info"
logging__rotation__enabled = "false"
logging__transports = ["stdout"]
mail__options__auth__user = "****@eexit.net"
# mail__options__auth__pass = $ fly secrets list
mail__options__service = "Mailgun"
mail__transport = "SMTP"
paths__contentPath = "/var/lib/ghost/content/"
privacy__useGravatar = "true"
privacy__useRpcPing = "true"
privacy__useStructuredData = "true"
privacy__useUpdateCheck = "true"
server__host = "0.0.0.0"
storage__active = "ghost-storage-cloudinary"
storage__ghost-storage-cloudinary__fetch__cdn_subdomain = "true"
storage__ghost-storage-cloudinary__fetch__secure = "true"
storage__ghost-storage-cloudinary__fetch__transformation = "blog"
storage__ghost-storage-cloudinary__upload__folder = "blog"
storage__ghost-storage-cloudinary__upload__overwrite = "false"
storage__ghost-storage-cloudinary__upload__tags = ["blog", "dpr1"]
storage__ghost-storage-cloudinary__upload__unique_filename = "false"
storage__ghost-storage-cloudinary__upload__use_filename = "true"
url = "https://blog.eexit.net"

[build.args]
ADAPTER_VERSION = "v2.2.5"
GHOST_VERSION = "5.22"

[mounts]
destination = "/var/lib/ghost/content/data"
source = "data"

# Rest of the file...

I loved the serverless or firebase one-step deployment commands and that was another pro to moving to static websites.

Now, I can build and deploy my blog by simply typing fly deploy. It takes less than a minute to build and push the image while the new release rollout took literally 10 seconds! 🚀

2022-10-24T15:29:56Z runner[1516f262] cdg [info]Shutting down virtual machine
2022-10-24T15:29:56Z app[1516f262] cdg [info]Sending signal SIGINT to main child process w/ PID 528
2022-10-24T15:29:56Z app[1516f262] cdg [info][2022-10-24 15:29:56] WARN Ghost is shutting down
2022-10-24T15:29:56Z app[1516f262] cdg [info][2022-10-24 15:29:56] WARN Ghost has shut down
2022-10-24T15:29:56Z app[1516f262] cdg [info][2022-10-24 15:29:56] WARN Your site is now offline
2022-10-24T15:29:56Z app[1516f262] cdg [info][2022-10-24 15:29:56] WARN Ghost was running for 18 hours
2022-10-24T15:29:57Z app[1516f262] cdg [info]Starting clean up.
2022-10-24T15:29:57Z app[1516f262] cdg [info]Umounting /dev/vdc from /var/lib/ghost/content/data
2022-10-24T15:30:00Z runner[e74a577d] cdg [info]Starting instance
2022-10-24T15:30:02Z runner[e74a577d] cdg [info]Configuring virtual machine
2022-10-24T15:30:02Z runner[e74a577d] cdg [info]Pulling container image
2022-10-24T15:30:03Z runner[e74a577d] cdg [info]Unpacking image
2022-10-24T15:30:04Z runner[e74a577d] cdg [info]Preparing kernel init
2022-10-24T15:30:04Z runner[e74a577d] cdg [info]Setting up volume 'data'
2022-10-24T15:30:04Z runner[e74a577d] cdg [info]Opening encrypted volume
2022-10-24T15:30:04Z runner[e74a577d] cdg [info]Configuring firecracker
2022-10-24T15:30:04Z runner[e74a577d] cdg [info]Starting virtual machine
2022-10-24T15:30:04Z app[e74a577d] cdg [info]Starting init (commit: 249766e)...
2022-10-24T15:30:04Z app[e74a577d] cdg [info]Mounting /dev/vdc at /var/lib/ghost/content/data w/ uid: 0, gid: 0 and chmod 0755
2022-10-24T15:30:05Z app[e74a577d] cdg [info]Preparing to run: `docker-entrypoint.sh node current/index.js` as root
2022-10-24T15:30:05Z app[e74a577d] cdg [info]2022/10/24 15:30:05 listening on [fdaa:0:c25d:a7b:5b66:2:7465:2]:22 (DNS: [fdaa::3]:53)
2022-10-24T15:30:06Z app[e74a577d] cdg [info][2022-10-24 15:30:06] INFO Ghost is running in production...
2022-10-24T15:30:06Z app[e74a577d] cdg [info][2022-10-24 15:30:06] INFO Your site is now available on https://blog.eexit.net/
2022-10-24T15:30:06Z app[e74a577d] cdg [info][2022-10-24 15:30:06] INFO Ctrl+C to shut down
2022-10-24T15:30:06Z app[e74a577d] cdg [info][2022-10-24 15:30:06] INFO Ghost server started in 1.226s

The icing on the cake: Fly.io is run by nerds and they grant you dedicated Grafana access so you can dig your app metrics like a pro. Most low-cost hosting solutions only provide very basic metrics when they do. Here, I can have a decent overview of how my different releases behave, awesome!

With my low visitor traffic, I can keep having the pleasure to work with Ghost and keep it running for free!

Faster upgrades & release cycles, less maintenance time, and more time to write.
Looks like a winning move to me!

Are you as excited as I am?

If you're willing to move to Fly.io as well for your Ghost blog, just follow the instructions here: https://www.autodidacts.io/host-ghost-mysql8-on-fly-io-free-tier/