Published on August 23, 2023

Replacing Nixpack with a Docker Image on Railway

4 min read

Context

I have a project that is built using NextJS and is server rendered on Railway. Since I didn’t want to handle any building and packaging, I decided to use Railway’s default Nixpack configuration. So on every push, build is triggered on Railway which then installs all dependencies, builds the application and packages into a docker image for deployment.

The problem - Output Image Size

#457 #139 #6537

For a tiny app that requires few MB’s of storage, Nixpack builds will create docker images with size of 800MB to 1.3GB. The above issues are talking about the same problem.

Having a large image size does infact have its effects like:

  • Longer upload time in deployment, affects CI/CD billing
  • Occupies more disk space
  • More the contents of the image, more the attack vector

Identifying the cause

My initial suspect was the most obvious - node_modules. What if Nixpack also includes it when building the image? This can very well explain the size of the images.

Ideally, this shouldn’t happen since now it does seem to respect .gitignore. But in order to clear by doubts. I tried to remove node_modules after the build was done, just by playing with build commands. This ended up being unsuccessful since the app still needed some packages as it ran using next cli. And selectively removing unused libs was not worth it.

Even after digging deep into nixpack docs, I couldn’t find anything relevant that could solve this issue. And that’s when I thought when I even need it. Maybe Railway does support some other ways of buidlign apps.

Turns out, it does (but was not obvious in their console - mentioned here).

The platform actually respects if you already have a docker file and builds the application using it instead of their own buildpacks.

Custom docker image

This section may be intended for individuals who are new to Docker. Feel free to skip if you are an expert already.
After realizing that if I had to write my own Dockerfile, I would better write it in such a way that its platform independent and having it layered. Here is what I did:
  1. Pull the base node image and install dependencies

I chose to use alpine image since its was the smallest and I don’t depent on a lot of OS packages.

FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps

# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the package manager you are using
COPY package.json package-lock.json* ./
RUN npm ci;
  1. Once I had the dependencies installed, next is to build the application with the required environment variables


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Set the environment variable from Railway
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL

# Since I use prisma as my ORM
RUN npx prisma generate
RUN npm run build
  1. Once the build is complete, copy only the output of the build to a new layer and run the application

Note: The important change I did here was to enable standalone mode in NextJS so I can remove the dependency on node_modules entirely. More info here.

Setting the following config in next.config.js.

const nextConfig = {
  output: 'standalone',
  ...
}
# Production image, copy all the files and run next
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

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

ARG PORT
ENV PORT=$PORT

EXPOSE $PORT

ENV HOSTNAME 0.0.0.0

CMD ["node", "server.js"]

Once I had the Dockerfile in my repo, Railway started to use the file to create the image. This reduced my image size from 1.304GB to 76.83MB, reducing the publishing time by 5x.


In conclusion, transitioning from Nixpack to a custom Docker image on Railway significantly improved the efficiency of my NextJS project. Nixpack initially offered convenience, but its bloated image sizes caused deployment delays, increased storage usage, and security concerns. Railway supporting custom Dockerfiles allowed me to create a lean, optimized image.

The biggest lesson is the importance of creating perfectly tailored solutions to a particular problem and making smart choices based on good information to make the development process work better. It’s like finding the right tool for the job and making informed decisions to make things go smoother and faster.

🎉 Interested in Frontend or Indie-hacking?

I talk about the latest in frontend, along with my experience in building various (Indie) side-projects