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:
- The Polotno editor bundle (
polotno-bundle.js+.css) — built frompolotno-editor/with Parcel. - 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_modulesdirectory is empty. This is the most common production-only failure mode — usually points at an EFS mount issue. - The
.bin/nuxtsymlink is created if missing because EFS sometimes doesn't preserve symlinks. If you ever seenuxt: command not foundin production logs, this is the culprit. yarn startrunsnuxt start, which expects a built.nuxt/directory (produced in stage 2). Don't try to runyarn devin production.
Nginx (nginx.conf)¶
Two server blocks:
uat.someli.ai— proxies/tohttp://127.0.0.1:3000(the Nuxt process).mtn.someli.ai— serves a static maintenance page from/var/www/html/maintenance.
Notable:
client_max_body_size 200Mand matchingclient_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.aiinnginx.confis the UAT image's server name. Verify with the team whether prod uses a differentnginx.confor 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 namecomponent files resolving to the same nameDuplicate fileIcon not found/Image not found/File not foundModule not found/Cannot resolve/Failed to resolveWARN,WARNING,deprecatedcompilation error,Failed to compile,Type error,TypeErrorSyntaxError,Parse error,Unexpected tokenmonths 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:
- Add it to the Dockerfile
ARGlist (in the right stage — polotno vs main). - Add it to the
.env-from-args block in that stage. - 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:
- Run
yarn installlocally —yarn.lockwill update. Commit it. - If it's a Polotno dep, also commit
polotno-editor/yarn.lock. - 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.
- If the dep ships its own peer-dep requirements, check the existing
peerDependenciesblock inpackage.jsonand bump if needed. Several scripts innuxt.config.js → headare 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.