How to buildOctober 11, 20269 min read
Docker and deploymentStep 3 of 8

Docker from scratch: image, container, Dockerfile

In short

A Docker image is an immutable template made of layers: the OS, the runtime, dependencies and the app code. A container is an isolated process started from an image, with its own writable layer. Images are described in a Dockerfile; for Node.js a good Dockerfile is multi-stage, with .dockerignore, a non-privileged user and HEALTHCHECK, and data lives in volumes, not inside the container.

TutorialLevel: BeginnerDockerPractice: ~45–90 min

After this you will be able to

  • explain the difference between an image, a container, a layer, a registry and a volume
  • write a multi-stage Dockerfile for a Node.js app with .dockerignore, USER node and HEALTHCHECK, ordering instructions for the build cache
  • run a container with a port, environment variables and a volume and inspect it with docker ps, logs and exec
  • explain why docker stop can wait 10 seconds and fix it with SIGTERM handling or --init

Verified with

Docker Engine 29.6 (Docker Desktop, WSL 2)BuildKit, dockerfile:1Node.js 24.21 (node:24.21.0-alpine, Alpine 3.24)Express 5.3.0TypeScript 7.0.2

Checked against docs: October 11, 2026

Contents
  1. 01Image, container, registry: what Docker does
  2. 02The app we are going to package
  3. 03A first Dockerfile and what is wrong with it
  4. 04A proper Dockerfile: stages, .dockerignore, USER, HEALTHCHECK
  5. 05Layers and the build cache
  6. 06Running: run, ps, logs, exec, stop
  7. 07Ports, environment variables and volumes
  8. 08Common mistakes and exercises

Image, container, registry: what Docker does

The “works on my machine” problem comes from different environments: another Node.js version, another OS, a forgotten system library. Docker packages the app with everything it needs and runs it the same way on a laptop, in CI and on a server. The Docker documentation defines a container as an isolated process: unlike a virtual machine it has no kernel of its own — all containers on a machine share the host kernel, so they start in a fraction of a second.

Image

What it is
An immutable set of layers: files, libraries, runtime, code and the start command
Analogy
An installation disc

Container

What it is
A process started from an image plus a thin writable layer on top of the image layers
Analogy
An installed, running program

Dockerfile

What it is
A text recipe for building an image, one instruction per layer
Analogy
The disc's source code

Registry

What it is
Image storage; Docker Hub by default
Analogy
A disc shop

Volume

What it is
Data storage that outlives the container
Analogy
An external drive

A layer is a set of filesystem changes: added, modified and deleted files. Once created, a layer never changes, and identical layers are shared between images: node:24.21.0-alpine is downloaded once no matter how many apps use it. Try running Node.js without installing it:

terminal
$ docker run --rm node:24.21.0-alpine node -e "console.log(process.version, process.platform, process.arch)"
v24.21.0 linux x64
$ docker image ls node:24.21.0-alpine
IMAGE                 ID             DISK USAGE   CONTENT SIZE   EXTRA
node:24.21.0-alpine   ebfe2f904627        242MB           62MB        
Docker Engine 29.6 (Docker Desktop, Windows 11 + WSL 2), Git Bash, real output. --rm removes the container after exit.

The lesson assumes Docker is installed (Docker Desktop on Windows and macOS, Docker Engine on Linux) and that you know a Node.js project from “Node.js from scratch”. Commands were run in Git Bash; in PowerShell they are the same except time and sleep.

The app we are going to package

A small notes API on Express 5 and TypeScript: GET /health for liveness, GET and POST /api/notes, notes are saved to a JSON file in the DATA_DIR folder. TypeScript is only needed at build time — exactly the reason for a multi-stage Dockerfile. The SIGTERM handler lets docker stop shut the app down cleanly.

package.json
{
  "name": "notes-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^5.3.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^24.19.2",
    "typescript": "^7.0.2"
  }
}
Dependency versions are current on npm as of 2026-10-11.
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
Compiles src → dist.
src/server.ts
import express from "express";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

type Note = { id: number; text: string; createdAt: string };

const port = Number(process.env.PORT ?? 3000);
const dataDir = process.env.DATA_DIR ?? "./data";
const file = join(dataDir, "notes.json");

async function loadNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await readFile(file, "utf8")) as Note[];
  } catch {
    return [];
  }
}

const app = express();
app.use(express.json());

app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: Math.round(process.uptime()) });
});

app.get("/api/notes", async (_req, res) => {
  res.json(await loadNotes());
});

app.post("/api/notes", async (req, res) => {
  const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
  if (!text) {
    res.status(400).json({ error: "text is required" });
    return;
  }
  const notes = await loadNotes();
  const note: Note = { id: notes.length + 1, text, createdAt: new Date().toISOString() };
  notes.push(note);
  await mkdir(dataDir, { recursive: true });
  await writeFile(file, JSON.stringify(notes, null, 2));
  res.status(201).json(note);
});

const server = app.listen(port, () => {
  console.log(`notes-api listening on port ${port} (NODE_ENV=${process.env.NODE_ENV ?? "development"})`);
});

// docker stop sends SIGTERM: finish open requests and exit instead of waiting to be killed
process.on("SIGTERM", () => {
  console.log("SIGTERM received, shutting down");
  server.close(() => process.exit(0));
});
Verified: tsc with no errors, runs on Node.js 24.21 and 24.18.
bash
npm install express@5.3.0
npm install -D typescript@7.0.2 @types/express@5.0.6 @types/node@24.19.2
npx tsc && node dist/server.js
Installing dependencies and a local check without Docker; package-lock.json is needed for npm ci in the image.

A first Dockerfile and what is wrong with it

Let's start with the Dockerfile people write the first time. It works, and it shows the typical mistakes well.

Dockerfile.naive
FROM node:24.21.0-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Naive version — not for production.
terminal
$ docker build -t notes-api:naive -f Dockerfile.naive .
…
#6 [internal] load build context
#6 transferring context: 36.09MB 3.1s done
#7 [3/5] COPY . .
#8 [4/5] RUN npm install
#8 1.800 added 1 package, removed 1 package, and audited 79 packages in 2s
#9 [5/5] RUN npm run build
#10 naming to docker.io/library/notes-api:naive done
Excerpt from docker build --progress=plain output (Docker 29.6, BuildKit).
terminal
$ docker run -d --name notes-naive -p 127.0.0.1:8081:3000 notes-api:naive
a372e6afa938bdfd8a9c6ce1d2e2db88ef4e55460403ea4ae7e78902308e5d6d
$ sleep 2; docker exec notes-naive ps -o pid,user,args
PID   USER     COMMAND
    1 root     npm start
   18 root     {MainThread} node dist/server.js
   25 root     ps -o pid,user,args
$ time docker stop notes-naive
notes-naive

real	0m0.528s
user	0m0.060s
sys	0m0.077s
$ docker rm notes-naive
notes-naive
$ docker image ls notes-api
IMAGE                ID             DISK USAGE   CONTENT SIZE   EXTRA
notes-api:1.0        98bb9f6c383d        247MB         62.3MB        
notes-api:naive      fcc71eb114bb        354MB           93MB        
The naive image compared with the final one from the next section, real output.
  • COPY . . without .dockerignore sent 36 MB to the builder — mostly node_modules from a Windows machine; npm had to replace the package with the TypeScript compiler built for Windows. A .env with secrets would have ended up in the image the same way
  • Any code change invalidates the COPY . . layer cache, and npm install runs again on every build
  • TypeScript and type packages stayed in the image — build tools not needed at runtime: 354 MB vs 247 MB
  • Processes run as root; PID 1 is npm, not the app itself

A proper Dockerfile: stages, .dockerignore, USER, HEALTHCHECK

First, .dockerignore — the list of what does not go into the build context; the syntax is similar to .gitignore. Dependencies and dist are built inside the image, and secrets have no place there.

.dockerignore
# Do not send to the builder: dependencies and build output are created inside the image
node_modules
dist
data
# Secrets and local files never belong in an image
.env
.env.*
.git
npm-debug.log*
Dockerfile*
.dockerignore
Syntax — .gitignore-style patterns.

Now the multi-stage build: each FROM starts a new stage, and COPY --from takes only what is needed from a previous one. The final image gets Node.js, production dependencies and compiled code — no TypeScript and no sources.

Dockerfile
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24.21.0

# 1. Build: all dependencies, including TypeScript, compile src -> dist
FROM node:${NODE_VERSION}-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# 2. Production dependencies only, without devDependencies
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 3. Final image: Node.js, production node_modules and compiled code
FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production \
    DATA_DIR=/app/data
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "dist/server.js"]
Verified: Docker 29.6, BuildKit; the image builds, the container becomes healthy.

ARG NODE_VERSION before FROM

Why it is here
The Node.js version in one place, pinned exactly: a build a month later gets the same runtime

FROM … AS build / deps / runtime

Why it is here
Three stages: build, production dependencies, final image; --target build builds only the first

COPY package.json package-lock.json before COPY src

Why it is here
The npm ci layer is rebuilt only when dependencies change, not on every code edit

npm ci --omit=dev

Why it is here
An exact install from package-lock.json without devDependencies

ENV NODE_ENV=production

Why it is here
The variable persists in the image and is visible to the app

USER node

Why it is here
The node user (uid 1000) already exists in the official image; the process runs without root privileges

EXPOSE 3000

Why it is here
Documentation only: -p opens the port at run time

HEALTHCHECK

Why it is here
Docker calls /health every 10 seconds; exit code 0 means healthy, 1 a failure, 3 failures in a row — unhealthy

CMD ["node", …]

Why it is here
Exec form: PID 1 is Node.js itself and signals reach the app; not npm start
terminal
$ docker build -t notes-api:1.0 .
…
#9 [build 3/7] COPY package.json package-lock.json ./
#10 [build 4/7] RUN npm ci
#11 [deps 4/4] RUN npm ci --omit=dev
#11 0.960 added 65 packages, and audited 66 packages in 693ms
#10 1.585 added 78 packages, and audited 79 packages in 1s
#14 [build 6/7] COPY src ./src
#15 [build 7/7] RUN npm run build
#15 0.261 > notes-api@1.0.0 build
#15 0.261 > tsc
#16 [runtime 4/6] COPY --from=build /app/dist ./dist
#18 [runtime 6/6] RUN mkdir -p /app/data && chown node:node /app/data
#19 naming to docker.io/library/notes-api:1.0 done
Build output excerpt: the build and deps stages run in parallel.

Layers and the build cache

Every instruction that changes files produces a layer. On a rebuild Docker checks whether the instruction and the files it copies changed: if not, the layer comes from the cache (CACHED). Once a layer changes, all layers after it in that stage are rebuilt. That is why rarely changing things (dependencies) are copied earlier and frequently changing ones (code) later. Change one line in src/server.ts and rebuild:

terminal
$ docker build -t notes-api:1.1 .
…
#10 [build 3/7] COPY package.json package-lock.json ./
#10 CACHED
#9 [build 4/7] RUN npm ci
#9 CACHED
#12 [build 6/7] COPY src ./src
#12 DONE 0.0s
#13 [build 7/7] RUN npm run build
#13 DONE 0.3s
#14 [deps 4/4] RUN npm ci --omit=dev
#14 CACHED
#15 [runtime 3/6] COPY --from=deps /app/node_modules ./node_modules
#15 CACHED
#16 [runtime 4/6] COPY --from=build /app/dist ./dist
#16 DONE 0.0s
Excerpt: npm ci in both stages comes from the cache; only the code and the dist layer were rebuilt.
terminal
$ docker history notes-api:1.0 --format 'table {{.CreatedBy}}\t{{.Size}}' | head -9
CREATED BY                                      SIZE
CMD ["node" "dist/server.js"]                   0B
HEALTHCHECK {Test:[CMD node -e fetch('http:/…   0B
EXPOSE [3000/tcp]                               0B
USER node                                       0B
RUN /bin/sh -c mkdir -p /app/data && chown n…   12.3kB
COPY package.json ./ # buildkit                 12.3kB
COPY /app/dist ./dist # buildkit                16.4kB
COPY /app/node_modules ./node_modules # buil…   4.44MB
Layers of the final image from top to bottom; below are the layers of the base node:24.21.0-alpine.

This shows that of the 247 MB image only about 4.5 MB are our own layers; the rest is the base image, already downloaded on the server and shared by every app on it. The documentation recommends rebuilding images regularly (docker build --pull) — an image is a snapshot, and security updates of the base image get into it only on a rebuild.

Running: run, ps, logs, exec, stop

Start the container in the background (-d) with a name and a port, and walk through the main commands.

terminal
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
14431bf467dc7af7235e5ea4abf38c7864f7775543f5499901030e23ae2c6f06
$ docker ps --filter name=notes
CONTAINER ID   IMAGE              COMMAND                  CREATED                  STATUS                                     PORTS                      NAMES
14431bf467dc   notes-api:1.0      "docker-entrypoint.s…"   Less than a second ago   Up Less than a second (health: starting)   127.0.0.1:8080->3000/tcp   notes
$ curl -s http://127.0.0.1:8080/health
{"status":"ok","uptime":0}$ sleep 8; docker ps --filter name=notes
CONTAINER ID   IMAGE              COMMAND                  CREATED         STATUS                   PORTS                      NAMES
14431bf467dc   notes-api:1.0      "docker-entrypoint.s…"   9 seconds ago   Up 8 seconds (healthy)   127.0.0.1:8080->3000/tcp   notes
$ docker logs notes
notes-api listening on port 3000 (NODE_ENV=production)
$ curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Buy coffee filters"}'
{"id":1,"text":"Buy coffee filters","createdAt":"2026-10-12T01:58:22.446Z"}$ docker exec notes whoami
node
$ docker exec notes ls -la /app
total 28
drwxr-xr-x    1 root     root          4096 Oct 12 01:57 .
drwxr-xr-x    1 root     root          4096 Oct 12 01:58 ..
drwxr-xr-x    1 node     node          4096 Oct 12 01:58 data
drwxr-xr-x    2 root     root          4096 Oct 12 01:57 dist
drwxr-xr-x   67 root     root          4096 Oct 12 01:57 node_modules
-rwxr-xr-x    1 root     root           329 Oct 12 01:56 package.json
$ docker exec notes cat /app/data/notes.json
[
  {
    "id": 1,
    "text": "Buy coffee filters",
    "createdAt": "2026-10-12T01:58:22.446Z"
  }
]$ docker exec notes ps -o pid,user,args
PID   USER     COMMAND
    1 node     {MainThread} node dist/server.js
   49 node     ps -o pid,user,args
$ docker inspect --format '{{.State.Health.Status}} {{len .State.Health.Log}}' notes
healthy 1
$ time docker stop notes
notes

real	0m0.487s
user	0m0.046s
sys	0m0.091s
$ docker logs notes --tail 2
notes-api listening on port 3000 (NODE_ENV=production)
SIGTERM received, shutting down
$ docker ps -a --filter name=notes
CONTAINER ID   IMAGE              COMMAND                  CREATED          STATUS                              PORTS     NAMES
14431bf467dc   notes-api:1.0      "docker-entrypoint.s…"   11 seconds ago   Exited (0) Less than a second ago             notes
$ docker rm notes
notes
Real output. During verification the objects had a kb- prefix; it is removed in the article.

docker run -d --name notes -p 127.0.0.1:8080:3000 image

What it does
Create and start a container in the background

docker ps, docker ps -a

What it does
Running containers; with -a stopped ones too

docker logs notes, docker logs -f notes

What it does
App output; -f follows in real time

docker exec notes command, docker exec -it notes sh

What it does
Run a command inside a running container; -it for an interactive shell

docker stop notes

What it does
SIGTERM, then SIGKILL after 10 seconds

docker rm notes, docker rm -f notes

What it does
Remove a stopped container; -f stops and removes

Why stopping took half a second. docker stop sends SIGTERM to the main process and waits 10 seconds, then kills it with SIGKILL. A process with PID 1 is special on Linux: signals without a handler have no effect on it. The Node.js image documentation states plainly that Node.js was not designed to run as PID 1. Here is what happens without a handler:

terminal
$ docker run -d --name pid1 node:24.21.0-alpine node -e "setInterval(() => {}, 1000)"
7e0e9b2a5a1331b23b772ba1db38099350be54a98ca08d411a421b600561039b
$ time docker stop pid1
pid1

real	0m10.399s
user	0m0.061s
sys	0m0.091s
$ docker ps -a --filter name=pid1 --format '{{.Names}}: {{.Status}}'
pid1: Exited (137) Less than a second ago
$ docker rm pid1
pid1
$ docker run -d --init --name pid1-init node:24.21.0-alpine node -e "setInterval(() => {}, 1000)"
b334728e597d7e4eabd09cf15e46edc9b3a18b182c21673db9f04a491b9a5ce3
$ time docker stop pid1-init
pid1-init

real	0m0.339s
user	0m0.046s
sys	0m0.091s
$ docker rm pid1-init
pid1-init
Real output: without a handler — a 10-second wait and exit code 137 (128 + 9, SIGKILL); with --init — instant.

Two fixes: handle SIGTERM in the app, as our server.ts does, or start the container with --init — then PID 1 is a tiny init process that forwards signals to the app. The handler is better: the app gets to close connections and finish writing data.

Ports, environment variables and volumes

A container has its own network: the app listens on port 3000 inside the container, and -p HOST:CONTAINER forwards a host port to it. Environment variables are passed with -e or --env-file. Files written inside a container disappear with it — data needs a volume.

terminal
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
25d39d3a88a744f5c87357a3f681ab44b8e0a6544b893d30c0fcca8fa6a2370d
$ sleep 1; curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Call the supplier"}'
{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:24.178Z"}
$ docker rm -f notes
notes
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
f8605e984fe84d46c12abae2c5663e3f0eba212076956b73d1baa01a9d353f73
$ sleep 1; curl -s http://127.0.0.1:8080/api/notes
[]
$ docker rm -f notes
notes
$ docker volume create notes-data
notes-data
$ docker run -d --name notes -p 127.0.0.1:8080:3000 -v notes-data:/app/data notes-api:1.0
05589d4fff5d56e64bfe16409e085c0bb4b5164a84d0d8f621a751f45613db24
$ sleep 1; curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Call the supplier"}'
{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:28.587Z"}
$ docker rm -f notes
notes
$ docker run -d --name notes -p 127.0.0.1:8080:3000 -v notes-data:/app/data notes-api:1.0
523718889c0c184fd4e10b90cff736bedae3ad6befec681f5f986c0080a087ad
$ sleep 1; curl -s http://127.0.0.1:8080/api/notes
[{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:28.587Z"}]
$ docker volume ls --filter name=notes-data
DRIVER    VOLUME NAME
local     notes-data
$ docker volume inspect notes-data --format '{{.Name}} {{.Driver}} {{.Mountpoint}}'
notes-data local /var/lib/docker/volumes/notes-data/_data
$ docker run -d --name notes-2 -p 127.0.0.1:8080:3000 notes-api:1.0
ee5dd4dd6bd165af172b5f0c9d1716414492a49852293f4cfc789585f0665489
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint notes-2 (9e06e8e275da8ac1e377b36926f60ee4c9c68cd70b86c024a7d380d7c640f326): Bind for 127.0.0.1:8080 failed: port is already allocated

Run 'docker run --help' for more information
$ docker rm notes-2
notes-2
$ docker run -d --name notes-public -p 8090:3000 -e NODE_ENV=staging notes-api:1.0
c73d3880d9022ed756df223c9a44be3f1a1ded52075cd780c75ac2ba77687a11
$ docker ps --filter name=notes --format 'table {{.Names}}\t{{.Ports}}'
NAMES             PORTS
notes-public      0.0.0.0:8090->3000/tcp, [::]:8090->3000/tcp
notes             127.0.0.1:8080->3000/tcp
$ sleep 1; docker logs notes-public
notes-api listening on port 3000 (NODE_ENV=staging)
$ docker rm -f notes notes-public
notes
notes-public
Real output: without a volume the note disappeared with the container; with the notes-data volume it survived re-creation.
  • -p 8090:3000 without an address publishes the port on all interfaces (0.0.0.0 and [::]). The Docker documentation explicitly calls port publishing insecure by default: on a server bind to 127.0.0.1 and expose the app through a reverse proxy
  • One host port — one container: the second attempt to take 127.0.0.1:8080 got port is already allocated; the created container stayed in Created state and had to be removed
  • A named volume survives container removal; docker run --rm removes only anonymous volumes
  • An empty volume is populated from the image folder on first mount, owner included. That is why the Dockerfile creates /app/data and gives it to node — otherwise the non-root app cannot write
  • For development, a host folder is mounted instead of a volume (bind mount, -v $(pwd)/src:/app/src): convenient, but tied to the machine's filesystem

Common mistakes and exercises

port is already allocated

Cause
The host port is taken by another container or program
What to do
Use another host port or stop whoever holds it; check docker ps

curl: (52) Empty reply or no answer while the container runs

Cause
The app listens on 127.0.0.1 inside the container
What to do
Listen on 0.0.0.0 inside the container (Express does this by default)

EACCES: permission denied on write

Cause
The folder in the image or volume belongs to root while the process runs as node
What to do
Create the folder and chown it in the Dockerfile before USER

Every build reinstalls dependencies

Cause
Code is copied before npm ci
What to do
First package.json, package-lock.json and npm ci, then the code

docker stop waits 10 seconds

Cause
PID 1 does not handle SIGTERM
What to do
A signal handler, --init, exec-form CMD

Data lost after an update

Cause
It was written to the container layer
What to do
A named volume for everything that must outlive the container

Next step — Docker Compose: the same image, but together with PostgreSQL and Redis, a network, volumes and health checks in one file and one command.

How this was verified12 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.