8000 GitHub - klirix/jig: Dead simple deployment tool · GitHub
[go: up one dir, main page]

Skip to content
klirixPublic

About

Dead simple deployment tool

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Repository files navigation

Jig — Dead simple deployment tool

noun

  a device that holds a piece of work and guides the tool operating on it.

Warning

Jig is still experimental and client/server versions are expected to stay in sync.

Installation

Installation is done in two steps:

  1. Server setup: run a startup script on the server
  2. Client setup: install the CLI and log in

Server setup

Docker is required on the server.

curl -fsSL https://deploywithjig.askh.at/init.sh | bash

This 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 domain
  • tailscale: keep the Jig API private on your tailnet with tailscale serve

If the tailscale CLI is already installed on the server, the script defaults to tailscale.

Tailscale control plane mode

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 jig service through public Traefik routes
  • configures tailscale serve to proxy the control plane to https://<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:5050 by 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.

Swarm worker setup

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/tcp from external networks

Example:

curl -fsSL https://deploywithjig.askh.at/worker.sh | bash

You 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 worker

To print a ready-to-run worker bootstrap command, use:

jig cluster join-worker

Swarm-backed server setup

Use 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 | bash

On a Swarm manager, the script now:

  • detects Swarm automatically
  • deploys the jig server as a Swarm service in public mode
  • keeps the jig control plane as a local manager container in tailscale mode
  • 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:

  1. Initialize Swarm on the manager node if it is not already active:
docker swarm init
  1. Create the shared overlay network that Jig and Traefik will use:
docker network create --driver overlay --attachable jig

This network is intentionally unencrypted. See the note on overlay encryption below before adding --opt encrypted.

  1. Create a persistent directory for Jig state on the manager:
mkdir -p /var/jig
  1. 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-token

If you do not use Vercel DNS challenge, leave JIG_VERCEL_APIKEY unset and Jig will configure Traefik with the HTTP challenge instead.

  1. Pull the Jig image:
docker pull askhatsaiapov/jig:latest
  1. 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
  1. Watch the service logs until startup completes:
docker service logs -f jig

On 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.

  1. Fetch the initial login token from the service logs:
docker service logs jig --tail 50

Look for the printed jig login https://...+TOKEN line and use that on your workstation.

  1. Point DNS for JIG_DOMAIN at any node in the Swarm. Traefik publishes ports 80 and 443 through the routing mesh, so every node forwards to it.

  2. 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/tcp from external networks. If that port is reachable publicly, the registry is a serious security risk.
  • The jig overlay 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.requiredNodeLabels in jig.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 80 and 443 on every node.
  • Swarm reports no port information to Traefik, so any service Traefik routes to must set port in its jig.json or x-jig block. Jig rejects the deploy otherwise.

Client setup

Install the CLI:

curl -fsSL https://deploywithjig.askh.at/install.sh | bash

This 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 | bash

Check 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 init

jig 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.

Common workflow

  1. Initialize the project with jig init
  2. Edit jig.json and, for compose projects, add composeFile plus any x-jig blocks in the compose file
  3. Deploy with jig deploy
  4. Check the deployment with jig ls, jig logs <name>, or jig stats
  5. For compose stacks, target a child service with stack:service, for example jig deploy my-stack:api or jig deployments logs my-stack:api
  6. Delete it with jig rm <name> or jig deployments rm <name>

Deployments

Jig supports two deployment modes.

Single-container deployments

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:

  • name
  • port
  • restartPolicy
  • domain
  • hostname
  • rule
  • envs
  • exposePorts
  • volumes
  • middlewares
  • placement.requiredNodeLabels in 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>

Compose deployments

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:

  • name
  • composeFile
  • composeService
  • restartPolicy
  • domain
  • hostname
  • rule
  • envs
  • middlewares

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-jig block inside the compose file.
  • Each service with x-jig becomes its own child service with its own route, middleware, and resolved secrets.
  • Services without x-jig still run as part of the compose project but remain internal to Jig.
  • Use stack:service for targeted commands like logs, delete, and rollback checks.
  • Jig connects each managed x-jig service container to the jig Docker network.
  • Volumes must be declared in the compose file itself. jig.json volumes are rejected for compose deployments.
  • jig deploy -l is 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:16

Matching 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 frontend routed at app.example.com
  • one child service named api routed at api.example.com
  • one internal db service that still runs but does not appear as its own Jig deployment

Secrets

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>

Reading jig ls

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.

Updating one service of a stack

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:worker

Only 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.

Other CLI features

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

Releasing

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 push

The 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.

Notes

  • Traefik is used for HTTP/TLS routing.
  • Compose deployments require docker compose to be available on the server.
  • .jigignore controls which project files are uploaded during deployment.

Testing

  • 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 integration build tag.
  • The integration target runs only TestComposeDeploymentE2E to avoid unrelated package tests that use a shared on-disk SQLite fixture.

About

Dead simple deployment tool

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

0