Skip to content

07 — Build & deploy

This doc explains how the app gets from a git push to a running production process. Useful when CI fails, when something only breaks in prod, or when you need to add a new env var.

Two artefacts

A production deploy produces two artefacts that must be built in the right order:

  1. The Polotno editor bundle (polotno-bundle.js + .css) — built from polotno-editor/ with Parcel.
  2. The Nuxt app — built from the repo root with Nuxt 2's webpack pipeline. Imports the polotno bundle directly.

Build order matters: the Nuxt build resolves import { createEditor } from '../../../../polotno-bundle' from disk, so the polotno bundle must exist before the Nuxt build starts.

The Dockerfile (3 stages)

Dockerfile is a straight-forward 3-stage build:

Stage 1 — polotno-build

FROM node:20.18.3-slim AS polotno-build
WORKDIR /polotno-editor

COPY polotno-editor/package.json polotno-editor/yarn.lock* ./
RUN yarn install --frozen-lockfile
COPY polotno-editor/ ./

# Build args become a .env file
RUN echo "REACT_APP_BASE_URL=${REACT_APP_BASE_URL}" > .env && \
    echo "GEMINI_AI_KEY=${GEMINI_AI_KEY}" >> .env && \
    # …more keys…

RUN yarn build
RUN cp /polotno-bundle.* /tmp/ || true

Note the bundle is copied to /tmp so stage 2 can pull it in with a COPY --from=polotno-build. The bundle path (/polotno-bundle.*) reflects Parcel writing one level above polotno-editor/ (i.e. /polotno-editor/../polotno-bundle.js = /polotno-bundle.js).

Stage 2 — main-build

FROM node:20.18.3-slim AS main-build
WORKDIR /app

COPY package.json yarn.lock* ./
RUN yarn install --frozen-lockfile
COPY . ./
COPY --from=polotno-build /tmp/polotno-bundle.* ./

# Build args become a .env file (same pattern as stage 1)
RUN echo "API_URL=${API_URL}" > .env && \
    # …all the runtime env vars…

RUN yarn build

# node_modules will be mounted via EFS at runtime
RUN rm -rf node_modules polotno-editor/node_modules
RUN rm -f .env

Note the deliberate removal of node_modules at the end of stage 2. The runtime image expects node_modules to arrive via an EFS mount, not to be baked in. This keeps the image small but introduces the boot-time mount-wait step (see start.sh).

Stage 3 — runtime

FROM node:20.18.3-slim
RUN apt-get install  nginx curl
WORKDIR /usr/src/app

COPY --from=polotno-build /polotno-editor ./polotno-editor
COPY --from=main-build /app ./

COPY nginx.conf /etc/nginx/nginx.conf
COPY start.sh /usr/src/app/start.sh
RUN chmod +x /usr/src/app/start.sh

EXPOSE 80 443
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD curl -f http://localhost:80/ || exit 1

CMD ["bash", "/usr/src/app/start.sh"]

Nginx in front, Nuxt behind, EFS for node_modules.

start.sh — the runtime boot script

# Wait up to 60s for /usr/src/app/node_modules to be populated (EFS mount)
# Wait up to 60s for /usr/src/app/polotno-editor/node_modules to be populated
# Verify nuxt exists in node_modules
# Symlink node_modules/nuxt/bin/nuxt.js into node_modules/.bin/nuxt if missing
# nginx -t and start nginx
# yarn start (which runs nuxt start on port 3000)

A few details worth knowing:

  • The script fails fast (60s timeout) if either node_modules directory is empty. This is the most common production-only failure mode — usually points at an EFS mount issue.
  • The .bin/nuxt symlink is created if missing because EFS sometimes doesn't preserve symlinks. If you ever see nuxt: command not found in production logs, this is the culprit.
  • yarn start runs nuxt start, which expects a built .nuxt/ directory (produced in stage 2). Don't try to run yarn dev in production.

Nginx (nginx.conf)

Two server blocks:

  1. uat.someli.ai — proxies / to http://127.0.0.1:3000 (the Nuxt process).
  2. mtn.someli.ai — serves a static maintenance page from /var/www/html/maintenance.

Notable:

  • client_max_body_size 200M and matching client_body_timeout 600s — large file uploads (FilePond, AWS S3) require this.
  • proxy_buffering off; proxy_request_buffering off; proxy_max_temp_file_size 0; on the proxy block — also for uploads.
  • The hardcoded server_name uat.someli.ai in nginx.conf is the UAT image's server name. Verify with the team whether prod uses a different nginx.conf or whether DNS is what differentiates environments.

The Jenkins pipeline (Jenkinsfile)

The Jenkins job does not build a Docker image. It SSHes into a target server (54.190.153.94 for the dev_app branch) and runs the build directly there, supervised by PM2. The Dockerfile-based path is for image-based deploys (likely UAT/prod via a separate workflow — verify with the team).

The Jenkins flow is:

1. Connect to ${DEPLOY_HOST} as ubuntu via sshagent
2. Clone or pull ${BRANCH} into ${DEPLOY_PATH}
3. cd polotno-editor && yarn install --frozen-lockfile && yarn build
4. cd ..             && yarn install --frozen-lockfile && yarn build
5. Both build outputs are tee'd to log files; the script greps them for warnings
6. Find PM2 binary, then either:
    - pm2 reload dev_app          (if the process exists)
    - pm2 start "yarn start" --name dev_app --node-args="--max-old-space-size=2048"
7. pm2 save
8. Cleanup credentials

The PM2 process is named dev_app (the branch and the process share the name).

Build warnings → exit code 100

The pipeline scans build logs for a curated list of regex patterns:

  • Two component files resolving to the same name
  • component files resolving to the same name
  • Duplicate file
  • Icon not found / Image not found / File not found
  • Module not found / Cannot resolve / Failed to resolve
  • WARN, WARNING, deprecated
  • compilation error, Failed to compile, Type error, TypeError
  • SyntaxError, Parse error, Unexpected token
  • months old, update-browserslist-db

If matches are found but the build still succeeded, the pipeline exits with status 100, the deploy still happens, and a Teams message is sent with a "succeeded with warnings" tag. The full warning list is sent in the message body (truncated at 2000 chars).

If the build itself fails (non-zero exit from yarn build), the pipeline exits with that build's exit code and Teams gets a failure message that includes the last 100 lines of build logs.

In practice, this means you can ship with warnings — but the on-call channel will see them. Treat exit-100 deploys as cleanup tasks; don't let warnings accumulate.

Branches and environments

Jenkinsfile hard-codes BRANCH = 'dev_app' and DEPLOY_HOST = '54.190.153.94'. There are likely separate Jenkinsfiles or pipeline configs for UAT and prod — verify with the team which branches map to which environments. The recent git log shows a uat_app branch being merged into main, suggesting UAT runs from uat_app and prod from main, but this should be confirmed.

Environment variables (production)

Every env var consumed by the production build is declared as a Docker ARG in Dockerfile. The full list is duplicated in 01-local-setup.md. Anything you add to the build must be added in three places:

  1. Add it to the Dockerfile ARG list (in the right stage — polotno vs main).
  2. Add it to the .env-from-args block in that stage.
  3. Add it to whatever CI configuration passes the value (the Jenkinsfile in this repo doesn't, suggesting CI/CD env vars are injected by Jenkins credentials/parameters or by the Docker build step in a separate pipeline — verify with the team).

If a variable shows up empty in production but is set in your .env locally, the Docker ARG propagation is the most likely culprit.

Local production-mode build

To reproduce a production build on your machine without Docker:

cd polotno-editor
yarn install --frozen-lockfile
yarn build

cd ..
yarn install --frozen-lockfile
yarn build      # produces .nuxt/
yarn start      # runs nuxt start on :3000

If yarn start fails with "ENOENT: no such file or directory, .nuxt/dist/server", you missed yarn build.

CI/CD checklist for new dependencies

Adding a dependency to either package.json:

  1. Run yarn install locally — yarn.lock will update. Commit it.
  2. If it's a Polotno dep, also commit polotno-editor/yarn.lock.
  3. Don't add things that need a native build step (node-gyp) without team review — the slim base image doesn't have build tools by default.
  4. If the dep ships its own peer-dep requirements, check the existing peerDependencies block in package.json and bump if needed. Several scripts in nuxt.config.js → head are intentional duplicates from CDN — talk to the team before "deduplicating" by moving things to npm.

Sonar / static analysis

sonar-project.properties is checked in, suggesting SonarQube/SonarCloud analysis runs somewhere. Verify with the team which CI step invokes it and what the quality gate is.