App Platform Inconsistencies

- 9 mins read

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
├── 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
├── 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 && 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.
RUN curl -L -o /tmp/hugo.tar.gz${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.
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.
RUN curl -L -o /tmp/hugo.tar.gz${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.
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


# 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.
RUN curl -L -o /tmp/hugo.tar.gz${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.
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


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!