noun
a device that holds a piece of work and guides the tool operating on it.
Jig is still experimental and client/server versions are expected to stay in sync.
Installation is done in two steps:
- Server setup: run a startup script on the server
- Client setup: install the CLI and log in
Docker is required on the server.
curl -fsSL https://deploywithjig.askh.at/init.sh | bashThis installs Traefik and Jig, asks for the initial settings, and prints a login command for the client. The bootstrap script now offers two control-plane modes:
public: expose the Jig API through Traefik on a public domaintailscale: keep the Jig API private on your tailnet withtailscale serve
If the tailscale CLI is already installed on the server, the script defaults to tailscale.
Use this when you want deployed apps to stay public, but you want the Jig API itself to be reachable only over your tailnet.
Requirements:
- Tailscale installed and connected on the server
- HTTPS enabled for Tailscale Serve in your tailnet
- Tailscale ACLs that limit who can reach the manager node
In tailscale mode, init.sh:
- does not expose the
jigservice through public Traefik routes - configures
tailscale serveto proxy the control plane tohttps://<node>.<tailnet>.ts.net - makes the printed
jig login ...command use that Tailscale URL - leaves Traefik public for application traffic
Networking details:
- standalone mode binds the Jig API to
127.0.0.1:5050, then exposes it through Tailscale Serve - Swarm mode keeps the Jig API on
127.0.0.1:5050by running the control plane as a local manager container while still using the Swarm backend
In Tailscale mode, the bootstrap keeps the control plane loopback-only automatically. You do not need a separate public firewall exception for 5050.
Use this on every additional node that should join the Swarm and run Jig workloads.
The node needs Docker and jq installed before you start. The bootstrap script:
- configures the Docker daemon for the internal Jig registry
- restarts Docker so that registry config takes effect
- joins the node to the Swarm
- reminds you to block inbound
5000/tcpfrom external networks
Example:
curl -fsSL https://deploywithjig.askh.at/worker.sh | bashYou will be prompted for:
- the Swarm join token
- the manager address
The script refuses to run without jq, rather than joining a node that would then
fail every image pull.
The manager can print a worker join token with:
jig cluster join-token workerTo print a ready-to-run worker bootstrap command, use:
jig cluster join-workerUse this path when the Jig server itself should run on a Docker Swarm manager and deploy single-service apps as Swarm services.
The easiest path is still the bootstrap script:
curl -fsSL https://deploywithjig.askh.at/init.sh | bashOn a Swarm manager, the script now:
- detects Swarm automatically
- deploys the
jigserver as a Swarm service inpublicmode - keeps the
jigcontrol plane as a local manager container intailscalemode - lets the server create Traefik as a Swarm service constrained to the Swarm managers
- lets the server create an internal registry service published through the Swarm routing mesh on
127.0.0.1:5000
Swarm nodes must trust that registry endpoint as an insecure registry:
{
"insecure-registries": ["127.0.0.1:5000"]
}Apply that daemon config on every Swarm node and restart Docker before deploying Swarm stacks.
Block inbound 5000/tcp from external networks on every node. If that port is reachable publicly, the registry is a serious security risk.
Only 80/tcp and 443/tcp should be exposed publicly for application traffic.
The worker bootstrap script can apply that daemon config automatically on Debian/Ubuntu systems if jq is present, otherwise it will tell you what to add.
Manual setup is still available if you want to control it directly:
- Initialize Swarm on the manager node if it is not already active:
docker swarm init- Create the shared overlay network that Jig and Traefik will use:
docker network create --driver overlay --attachable jigThis network is intentionally unencrypted. See the note on overlay encryption below
before adding --opt encrypted.
- Create a persistent directory for Jig state on the manager:
mkdir -p /var/jig- Choose the public domain for the Jig API and export the required env vars:
export JIG_DOMAIN=jig.example.com
export JIG_SSL_EMAIL=ops@example.com
export JIG_VERCEL_APIKEY=your-vercel-tokenIf you do not use Vercel DNS challenge, leave JIG_VERCEL_APIKEY unset and Jig will configure Traefik with the HTTP challenge instead.
- Pull the Jig image:
docker pull askhatsaiapov/jig:latest- Deploy the Jig server itself as a Swarm service on a manager node:
docker service create \
--name jig \
--constraint 'node.role == manager' \
--network jig \
--mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \
--mount type=bind,src=/var/jig,dst=/var/jig \
--env JIG_DOMAIN=$JIG_DOMAIN \
--env JIG_SSL_EMAIL=$JIG_SSL_EMAIL \
--env JIG_VERCEL_APIKEY=$JIG_VERCEL_APIKEY \
--label traefik.enable=true \
--label traefik.docker.network=jig \
--label traefik.http.services.jig.loadbalancer.server.port=5000 \
--label 'traefik.http.middlewares.https-only.redirectscheme.scheme=https' \
--label 'traefik.http.middlewares.https-only.redirectscheme.permanent=true' \
--label "traefik.http.routers.jig.rule=Host(\`$JIG_DOMAIN\`)" \
--label 'traefik.http.routers.jig.entrypoints=web' \
--label 'traefik.http.routers.jig.middlewares=https-only' \
--label "traefik.http.routers.jig-secure.rule=Host(\`$JIG_DOMAIN\`)" \
--label 'traefik.http.routers.jig-secure.entrypoints=websecure' \
--label 'traefik.http.routers.jig-secure.tls=true' \
--label 'traefik.http.routers.jig-secure.tls.certresolver=defaultresolver' \
askhatsaiapov/jig:latest- Watch the service logs until startup completes:
docker service logs -f jigOn startup, Jig will detect that it is running on a Swarm manager, ensure the jig overlay network exists, and create a central Traefik Swarm service if one is not already present.
- Fetch the initial login token from the service logs:
docker service logs jig --tail 50Look for the printed jig login https://...+TOKEN line and use that on your workstation.
-
Point DNS for
JIG_DOMAINat any node in the Swarm. Traefik publishes ports80and443through the routing mesh, so every node forwards to it. -
Deploy applications normally with
jig deploy.
Notes for Swarm-backed deployments:
- Single-service non-Compose apps are deployed as Docker services.
- Compose deployments become Swarm stacks.
- Swarm stack builds are pushed to the internal registry at
127.0.0.1:5000, so every Swarm node must trust that endpoint as an insecure registry. - Block inbound
5000/tcpfrom external networks. If that port is reachable publicly, the registry is a serious security risk. - The
jigoverlay network is created unencrypted, matching Docker's default. Traffic between nodes is therefore not encrypted by Docker, so put the Swarm on a private network if the nodes talk over untrusted links. - Swarm apps with bind mounts must set
placement.requiredNodeLabelsinjig.json. - Jig stats are not available for Swarm deployments.
- The Jig server service should stay constrained to a manager because it needs Docker API access and Swarm control-plane access.
- Traefik is constrained to the Swarm managers, because it reads the Swarm API from the local Docker socket and only managers serve it. The routing mesh still publishes
80and443on every node. - Swarm reports no port information to Traefik, so any service Traefik routes to must set
portin itsjig.jsonorx-jigblock. Jig rejects the deploy otherwise.
Install the CLI:
curl -fsSL https://deploywithjig.askh.at/install.sh | bashThis installs the latest release for your OS and architecture. To pin a version or change where it lands:
JIG_VERSION=2.0.0 curl -fsSL https://deploywithjig.askh.at/install.sh | bash
JIG_INSTALL=/usr/local/lib/jig curl -fsSL https://deploywithjig.askh.at/install.sh | bashCheck what you have with jig --version.
Then log in with the token printed during server setup:
jig login <token>Create a starter config in a project directory:
jig initjig init works in any project directory. Use the generated jig.json as the starting point and
adjust it for a single-container app or a compose deployment.
- Initialize the project with
jig init - Edit
jig.jsonand, for compose projects, addcomposeFileplus anyx-jigblocks in the compose file - Deploy with
jig deploy - Check the deployment with
jig ls,jig logs <name>, orjig stats - For compose stacks, target a child service with
stack:service, for examplejig deploy my-stack:apiorjig deployments logs my-stack:api - Delete it with
jig rm <name>orjig deployments rm <name>
Jig supports two deployment modes.
This is the original mode. jig deploy uploads the project and builds the image on the server. jig deploy -l builds the image locally and uploads the image instead.
Supported jig.json fields in this mode:
nameportrestartPolicydomainhostnameruleenvsexposePortsvolumesmiddlewaresplacement.requiredNodeLabelsin Swarm mode when bind mounts are used
Example:
Minimal regular deployment:
jig.json
{
"name": "frontend",
"domain": "app.example.com",
"port": 3000,
"restartPolicy": "unless-stopped",
"envs": {
"API_URL": "https://api.example.com",
"SESSION_SECRET": "@session-secret"
},
"volumes": ["/var/lib/my-app:/app/data"]
}Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]Swarm-specific example with a bind mount pinned to labeled nodes:
{
"name": "frontend",
"domain": "app.example.com",
"port": 3000,
"restartPolicy": "unless-stopped",
"volumes": ["/var/lib/frontend:/app/data"],
"placement": {
"requiredNodeLabels": {
"jig.disk": "frontend-data"
}
}
}Apply the matching label on the target node before deploying:
docker node update --label-add jig.disk=frontend-data <node-name>If the project contains docker-compose.yaml, docker-compose.yml, compose.yaml, or compose.yml, Jig treats it as a grouped deployment. On standalone instances it uses docker compose. On Swarm-backed instances it deploys a Swarm stack.
You can pin the compose file and, for legacy single-deployment compose projects, the primary routed service in jig.json:
{
"name": "my-stack",
"composeFile": "docker-compose.yaml",
"composeService": "web",
"domain": "app.example.com",
"envs": {
"DATABASE_URL": "@database-url"
}
}Supported jig.json fields in compose mode:
namecomposeFilecomposeServicerestartPolicydomainhostnameruleenvsmiddlewares
Behavior in compose mode:
- Compose deployments render as a single stack with child services underneath it.
- Compose services can opt into separate Jig deployments with an
x-jigblock inside the compose file. - Each service with
x-jigbecomes its own child service with its own route, middleware, and resolved secrets. - Services without
x-jigstill run as part of the compose project but remain internal to Jig. - Use
stack:servicefor targeted commands like logs, delete, and rollback checks. - Jig connects each managed
x-jigservice container to thejigDocker network. - Volumes must be declared in the compose file itself.
jig.jsonvolumesare rejected for compose deployments. jig deploy -lis not supported for compose deployments.- Rollback is not supported for compose deployments.
Example multi-deployment compose file:
services:
frontend:
build: .
x-jig:
name: frontend
domain: app.example.com
envs:
PUBLIC_API_URL: https://api.example.com
api:
build: ./api
x-jig:
name: api
domain: api.example.com
envs:
DATABASE_URL: "@database-url"
db:
image: postgres:16Matching top-level jig.json for the compose project:
{
"name": "my-stack",
"composeFile": "docker-compose.yaml",
"restartPolicy": "unless-stopped"
}This gives you:
- one stack deployment named
my-stack - one child service named
frontendrouted atapp.example.com - one child service named
apirouted atapi.example.com - one internal
dbservice that still runs but does not appear as its own Jig deployment
Secrets are stored on the Jig server and can be referenced from jig.json env values with an @ prefix.
Example:
{
"envs": {
"API_TOKEN": "@prod-api-token"
}
}CLI support:
jig secrets ls
jig secrets add <name> <value>
jig secrets rm <name>
jig secrets inspect <name>Every deployment reports exactly one status:
| status | meaning |
|---|---|
running |
every replica asked for is up |
updating |
a rollout or rollback is in flight right now |
stale |
replicas are healthy, but the last deploy failed and never landed, so the previous version is still serving |
degraded |
some replicas are up, but not all |
failed |
nothing is up |
stopped |
nothing is meant to be up |
A stack reports the worst status among its services, except that a dead service alongside live ones
reads degraded rather than failed. replicas reads running/desired. Anything that is not
plainly running gets a note under the table explaining why, along with the commands to look into
it:
Deployments
1 service · 1 stack · 1 needing attention
NAME STATUS REPLICAS ROUTE AGE
jig-website running 2/2 deploywithjig.askh.at 4mo
radist degraded 4/5 1mo
├─ be running 1/1 radist.tech/api 1mo
├─ fe running 1/1 radist.tech 1mo
├─ redis running 1/1 1mo
└─ room_server failed 0/1 2h
! radist:room_server failed — exited with code 1
jig logs radist:room_server
jig deployments rollback radist:room_server
Use jig ls --wide for raw Traefik rules, deployment kinds, and rollback availability.
Deploying a whole stack to change one service is slow and restarts things that did not change. Name the service instead:
jig deploy my-stack:api
jig deploy my-stack:api my-stack:workerOnly the named services are built, pushed, and updated; the rest of the stack keeps running
untouched. The stack half must match name in jig.json, since the uploaded code comes from the
current directory. The service name is the one jig ls shows, which is x-jig.name when set;
services with no x-jig block can still be targeted by their compose service name.
Jig currently supports:
- listing deployments with
jig ls - deploying a whole project or single stack services
- deleting deployments
- rollback for single-container deployments
- viewing deployment logs
- viewing deployment stats
- managing secrets
- managing auth tokens
Releases are cut by the VERSION file at the repo root. Bump it on master and CI does the rest:
it tags v<VERSION>, builds the client for darwin and linux on both arm64 and amd64, publishes them
under that tag as the latest release, and pushes askhatsaiapov/jig:<VERSION> and
askhatsaiapov/jig:latest.
echo 2.1.0 > VERSION
git commit -am "release 2.1.0" && git pushThe version is stamped into both binaries at build time, so jig --version reports the release it
came from. A build made any other way reports dev.
Between releases, pushes touching pkgs/server/** publish askhatsaiapov/jig:edge and pushes
touching pkgs/client/** refresh the rolling client-release tag. Neither moves latest, which is
what install.sh and init.sh follow.
- Traefik is used for HTTP/TLS routing.
- Compose deployments require
docker composeto be available on the server. .jigignorecontrols which project files are uploaded during deployment.
- Unit tests:
env GOCACHE=/tmp/go-build-cache go test ./... - Compose end-to-end integration tests:
make test-integration - Integration tests require a working Docker daemon and are opt-in through the
integrationbuild tag. - The integration target runs only
TestComposeDeploymentE2Eto avoid unrelated package tests that use a shared on-disk SQLite fixture.