App Platform Inconsistencies
Not all app platforms are equal. I learned that when I migrated this website from Netlify to DigitalOcean App platform.
What I thought would be a simple migration turned into me working around assumptions present in Netlify that weren’t present in DigitalOcean App Platform.
Pre-Migration Architecture
This website was a static site generated by Hugo. It was hosted on Netlify with AWS Route53 serving DNS for the domain.
This architecture costed $0.50/month.
- Netlify: Free
- AWS Route53: $0.50/month for 1 hosted zone
Had I used my registrar’s DNS this architecture could have been completely free, but I was already using Route53 for other projects so I figured what’s another $0.50/month?
Deciding to Migrate
I’ve been a DigitalOcean customer since 2014. I’ve used it for personal projects, to learn new technologies, and to host infrastructure for educational material.
Months before I migrated this website I deployed sudomateo/discord-leetcode using DigitalOcean Functions, which runs on DigitalOcean App Platform. The overall experience was okay, but I wrote that off since DigitalOcean Functions was a relatively new product at the time.
At this point I was running a few projects in DigitalOcean and only one project in Netlify. Being the minimalist that I am, it was the perfect time to consolidate my infrastructure into DigitalOcean and delete my Netlify account. I really don’t like having accounts for services that I’m not using.
I wanted to take a moment to say that Netlify is a great platform. It has served me well in my journey. Ultimately, my decision to migrate came down to the following reasons.
- DigitalOcean App Platform supported deploying static sites.
- I was already using DigitalOcean for services that Netlify didn’t offer (e.g. object storage).
- I wasn’t using Netlify for anything other than this website.
The Migration
You would think that migrating a static site from one platform to another would be simple. In theory, you’d be right. In practice, not so much.
Before I describe the inconsistencies that I ran into let’s take a moment to see how this website was built and deployed.
This website was a static site generated by Hugo. To build it, run hugo
.
> hugo
Start building sites …
hugo v0.115.4-dc9524521270f81d1c038ebbb200f0cfa3427cc5+extended linux/amd64 BuildDate=2023-07-20T06:49:57Z VendorInfo=gohugoio
| EN
-------------------+------
Pages | 13
Paginator pages | 0
Non-page files | 0
Static files | 159
Processed images | 0
Aliases | 2
Sitemaps | 1
Cleaned | 0
Total in 55 ms
Hugo builds the website and places all of the static site files into a new
public
directory.
> tree -L 1 public
public
├── 404.html
├── about
├── css
├── fonts
├── index.html
├── index.xml
├── js
├── katex
├── page
├── posts
├── series
├── sitemap.xml
└── tags
10 directories, 4 files
You can then take this public
directory and serve it using your favorite web
server.
Simple enough, right?
Inconsistency 1: Assumptions About Your Code
I created an application on DigitalOcean App Platform and linked my GitHub repository to it. DigitalOcean parsed my repository and detected that it was… a Go project!? Huh, what happened? I thought it would detect that my repository was Hugo project, not a Go project.
I looked around for a setting to change the type of project for my application. Much to my surprise, no such setting existed! Netlify let me configure my build however I wanted but DigitalOcean offered no such capability. I was stuck with whatever DigitalOcean thought my project was, not what my project actually was.
Looking for a solution, I read the documentation and found out that DigitalOcean App Platform supported the following types of builds.
The documentation for the Hugo buildpack stated:
App Platform looks for any of the following to detect a Hugo application:
config.toml
config.yaml
config.json
Hm, I didn’t have any of those files in my Hugo project since I migrated from
Hugo’s config.toml
to the newer hugo.toml
.
The documentation for the Go buildpack stated:
App Platform looks for any of the following files to detect a Go application:
go.mod
Gopkg.toml
Godeps/Godeps.json
vendor/vendor.json
glide.yaml
There it was! I had a go.mod
file in my Hugo project since I migrated from Git
submodules to Hugo modules, which uses Go modules under the hood.
Here’s what the directory structure looked like for my Hugo project.
.
├── archetypes
├── content
├── go.mod
├── go.sum
├── hugo.toml
├── LICENSE
├── public
├── resources
├── static
└── terraform
I was in a predicament. I wanted to use buildpacks for my application but DigitalOcean would not let me change the type of project for my application from Go to Hugo.
DigitalOcean made assumptions about my code and wouldn’t let me change them.
No worries. I decided to debug this a bit and try to make the Go project work for me.
I updated the build command for my application to hugo
and configured the
application to use public
as the output directory. Didn’t work. The
application expected a main
package for this Go project. I created a main.go
with an empty main
function and tried again. This time the deployment failed
with an error that the hugo
command was not found. I download the hugo
command and chained commands together with &&
. Eventually, I was able to get a
deployment to succeed with the following build command.
curl -L -o hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v0.115.4/hugo_extended_0.115.4_linux-amd64.tar.gz && tar -xvf hugo.tar.gz -C bin hugo && hugo
That’s pretty messy. It looked like buildpacks wouldn’t work for me after all. Perhaps I’d have better luck with Dockerfiles.
Inconsistency 2: Missing Support for Docker Features
I decided to switch to using Dockerfiles instead of buildpacks for my application.
The documentation for using Dockerfiles wasn’t clear. Even after reading I had the following questions.
- How does a
Dockerfile
work with static sites?- Would the built container be deployed?
- Would the filesystem be extracted from the container?
I decided to try things out rather than wonder.
I figured if they were asking for a Dockerfile
then the built container would
be deployed. I unset the output directory and pushed the following Dockerfile
.
FROM golang:1.20.6 AS builder
# Install curl.
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Hugo.
ENV HUGO_VERSION=0.115.4
RUN curl -L -o /tmp/hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz && \
tar -xvf /tmp/hugo.tar.gz -C /usr/local/bin hugo && \
rm -rf /tmp/hugo.tar.gz
# Build the Hugo site.
WORKDIR /app
COPY . .
RUN hugo --destination public
# Final container image.
FROM nginx:1.25.1
COPY --from=builder /app/public /usr/share/nginx/html
Didn’t work. I needed an output directory to be set when using a Dockerfile
.
It looks like the application would extract the files from the Docker container
and serve them.
I updated my Dockerfile
and set the output directory to /app/public
.
FROM golang:1.20.6
# Install curl.
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Hugo.
ENV HUGO_VERSION=0.115.4
RUN curl -L -o /tmp/hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz && \
tar -xvf /tmp/hugo.tar.gz -C /usr/local/bin hugo && \
rm -rf /tmp/hugo.tar.gz
# Build the Hugo site.
WORKDIR /app
COPY . .
RUN hugo --destination public
Hey, it worked! I had a live site deployed and the deployment process wasn’t that messy.
I decided to update the Dockerfile
to support arm64 and amd64. I only used an
amd64 machine, but it’s a good practice to support multiple architectures if
possible.
FROM golang:1.20.6
ARG TARGETARCH
# Install curl.
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Hugo.
ENV HUGO_VERSION=0.115.4
RUN curl -L -o /tmp/hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-${TARGETARCH}.tar.gz && \
tar -xvf /tmp/hugo.tar.gz -C /usr/local/bin hugo && \
rm -rf /tmp/hugo.tar.gz
# Build the Hugo site.
WORKDIR /app
COPY . .
RUN hugo --destination public
That didn’t work. DigitalOcean didn’t support native Docker build features such
as TARGETARCH
.
DigitalOcean asked for a Dockerfile
that was neither representative of my
application nor supported native Docker features.
I was getting low on time so I decided to ignore this and finish the migration
to get the website live. The Dockerfile
build would have to suffice.
Inconsistency 3: It’s Always DNS
Now that the website was live on DigitalOcean App Platform it was time to update the DNS record to point to the new deployment.
I figured this would be a simple record update. Nope, it’s never simple!
To use a custom domain, DigitalOcean App Platform asks you to create a CNAME or ALIAS record for your domain pointing to the deployment URL of your application.
The domain for my website was the apex domain, not a subdomain. That means I couldn’t use a CNAME record since it’s not recommended to use a CNAME for the apex domain.
Okay, ALIAS record it is. I went into the Route53 console to create an ALIAS record for my domain pointing to the deployment URL of my application. Nope, you can’t do that with Route53. You can only create ALIAS records in Route53 that point to AWS resources.
DigitalOcean did not provide an alternative method of configuring DNS for the application deployment.
I didn’t sweat this inconsistency so much since it wasn’t a limitation of the app platform as much as it was a limitation of my DNS provider. Since I was trying to go to bed at this point, I switched the DNS for my domain to my registrar, which does support ALIAS records pointing anywhere. I created the necessary ALIAS record and went to bed.
When I woke up everything was working and I was finally done. My website was migrated!
Post-Migration Architecture
After the migration the website remained a static site generated by Hugo. It was hosted on DigitalOcean App Platform with my registrar serving DNS for the domain.
This architecture was completely free.
- DigitalOcean App Platform: Free via the Starter plan
- Registrar DNS: Free
Summary
This post isn’t meant to be a dig at which app platform is better or worse. All app platforms have their pros and cons. Some are great for specific things like static sites and others are more generic to cover multiple use cases. Pick the app platform that’s best for you whether that’s Netlify, DigitalOcean App Platform, etc.
Instead, I’d like this post to be a reminder that even the simplest migrations can have unintended side effects that turn a 5 minute job into a few hours. As engineers we should budget time for these unexpected side effects. Maybe you have to postpone your migration. Maybe you get creative on the spot and push forward. It’s all up to you!
I hope you learned that not all app platforms are equal. Take the time to learn the app platforms that you’d like to use. Find their inconsistencies, document them, and come up with a plan to mitigate their effects on your applications. After all that’s what good engineers do, and you’re a good engineer!