reploy is a small host-native container deployer. It accepts JWT-protected HTTP webhooks or trusted local Unix socket requests, pulls matching images, and updates the affected workloads. The default podman backend restarts systemd-managed Podman containers, while the optional docker-compose backend recreates Docker Compose services.
It is designed for webhook-triggered updates with request-scoped image patterns, merged concurrent requests, and one coordinated restart or recreation phase after all relevant pulls complete.
On the Reploy host, issue a token restricted to the image that the repository may deploy:
export REPLOY_JWT_SECRET='replace-with-at-least-32-random-bytes'
reploy token --image 'ghcr.io/averyanalex/my-app:latest'Add REPLOY_URL and REPLOY_TOKEN as GitHub repository secrets. REPLOY_URL
must be the full deploy endpoint, for example
https://reploy.example.com/deploy. Then add this copy-paste-ready step after
the workflow pushes its container image:
- name: Deploy with Reploy
uses: AveryanAlex/reploy-action@v1
with:
url: ${{ secrets.REPLOY_URL }}
token: ${{ secrets.REPLOY_TOKEN }}
images: ghcr.io/averyanalex/my-app:latestThe Action waits for deployment to complete and fails the workflow if Reploy
rejects the request or reports a deployment failure. See
reploy-action for multiple
images, timeout configuration, and outputs.
Reploy runs directly on the host, not inside the workloads it updates.
For the default Podman/systemd backend:
- Linux with systemd
- Root-owned Podman containers
- Containers generated/started by systemd with a
PODMAN_SYSTEMD_UNIT=<unit>.servicecontainer label - Containers opted in with Podman's
io.containers.autoupdate=registrylabel
For Quadlet, configure auto-update on the container:
[Container]
AutoUpdate=registryQuadlet generates the auto-update label and Podman stores the generated service name in the
container's PODMAN_SYSTEMD_UNIT label. Reploy reads both values from the container labels; it
does not read PODMAN_SYSTEMD_UNIT from the workload environment.
For containers created without Quadlet, set both labels:
--label io.containers.autoupdate=registry
--label PODMAN_SYSTEMD_UNIT=container-example.serviceFor the Docker Compose backend:
- A recent Docker Engine and the latest Docker Compose v2 plugin, including support for
docker compose up --wait - Host access to the
dockerCLI and permission to manage the target containers - The original Compose files, project directory, environment files, and interpolation environment available to Reploy at their original paths
- Registry-backed
image:services; build-only or intentionally local services should not be selected - Under the default opt-in policy, services explicitly opted in with Reploy's label:
labels:
io.reploy.autoupdate: registryThe value must be exactly registry; missing labels and other values are ignored. The Docker Compose backend uses only io.reploy.autoupdate for eligibility. Podman's io.containers.autoupdate label continues to apply to the Podman backend but has no effect on Compose services.
Docker and Compose do not define a canonical auto-update opt-in label. Compose does automatically add canonical com.docker.compose.* metadata labels to its containers; Reploy uses that metadata to identify the project and service, so those labels should not be added manually.
For projects where updating every service is the safer default, select the opt-out policy:
reploy serve --backend docker-compose --docker-compose-policy opt-outOpt-out mode includes every Compose service unless it is explicitly excluded:
labels:
io.reploy.autoupdate: "off"In opt-out mode, mark build-only and local-image services off; Reploy deliberately uses --no-build and updates registry images only.
reploy serve
reploy serve --backend docker-compose
reploy trigger --image 'ghcr.io/averyanalex/*'
reploy token --image 'ghcr.io/averyanalex/*' --expires-in 365dpodman is the default backend. Select Docker Compose with the command-line argument above or with an environment variable:
REPLOY_BACKEND=docker-compose reploy serve
reploy trigger --mode direct --backend docker-compose --image 'ghcr.io/example/*'Socket triggers always use the backend and Compose policy selected by the running server. The trigger's backend arguments apply only to direct execution and the direct fallback in --mode auto.
REPLOY_JWT_SECRET='replace-with-a-long-random-secret'
REPLOY_HTTP_ADDR='127.0.0.1:8080'
REPLOY_SOCKET_PATH='/run/reploy.sock'
REPLOY_BACKEND='docker-compose'
REPLOY_DOCKER_COMPOSE_POLICY='opt-out'REPLOY_JWT_SECRET is required by serve when HTTP is enabled and is always required by token. It must contain at least 32 bytes; generate a random value with a password manager or openssl rand -base64 32. A socket-only server does not need it. REPLOY_DOCKER_COMPOSE_POLICY defaults to opt-in.
Tokens default to one year when --expires-in is omitted.
export REPLOY_JWT_SECRET='replace-with-a-long-random-secret'
reploy token --image 'ghcr.io/averyanalex/*'The token claim contains allowed image patterns. A webhook request is accepted only when every requested image pattern is allowed by the token.
sudo REPLOY_JWT_SECRET='replace-with-a-long-random-secret' reploy serve \
--http-addr 0.0.0.0:8080 \
--socket-path /run/reploy.sockThe HTTP listener does not terminate TLS. Keep it on loopback or place it behind an HTTPS reverse proxy; never send bearer tokens over an untrusted plaintext connection.
curl -X POST 'http://127.0.0.1:8080/deploy' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"images":["ghcr.io/averyanalex/*"]}'The endpoint returns HTTP 200 when deployment completes and HTTP 500 with a structured deployment response when pulling images or restarting units fails. Failure responses include backend error details for operational convenience, so webhook recipients should be treated as trusted.
Progress streaming is opt-in through an explicit Accept: text/event-stream
header. Requests with no Accept header or the usual Accept: */* continue to
receive the single JSON response above.
curl -N -X POST 'http://127.0.0.1:8080/deploy' \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"images":["ghcr.io/averyanalex/*"]}'Each SSE message has a named event and JSON data with the same type field.
The stream starts with queued, reports discovery, matching images, individual
pulls, and restarts, then ends with exactly one completed or failed event.
The terminal event contains the same structured deployment response used by the
JSON API. Authentication and request-validation errors still use their normal
HTTP status and JSON error response. Once an SSE response has started, an
execution failure is reported by the terminal failed event on the HTTP 200
stream because the HTTP status can no longer be changed.
The local trigger first tries the Unix socket so manual runs and systemd timers merge with an already-running server. If the socket is unavailable, --mode auto falls back to direct standalone execution.
sudo reploy trigger --image 'ghcr.io/averyanalex/*'
sudo reploy trigger --mode socket --image 'ghcr.io/averyanalex/*'
sudo reploy trigger --mode direct --image 'ghcr.io/averyanalex/*'Waiting triggers stream deployment progress by default. Interactive terminals show a progress bar
with event logs; redirected output and TERM=dumb use stable plain logs followed by a summary.
Use --output plain to force plain output, or --output json to disable streaming and preserve the
legacy final JSON response. --no-stream also disables streaming while keeping the selected human
output format.
sudo reploy trigger --output plain --image 'ghcr.io/averyanalex/*'
sudo reploy trigger --output json --image 'ghcr.io/averyanalex/*'
sudo reploy trigger --no-stream --image 'ghcr.io/averyanalex/*'Use --no-wait with socket mode to enqueue and return immediately:
sudo reploy trigger --no-wait --image 'ghcr.io/averyanalex/*'Only one deploy worker runs at a time. If a request arrives while another request is processing, it is queued and merged into the next deploy cycle. Requests that arrive before the restart phase are pulled before the final restart or recreation, avoiding repeated update operations for closely spaced image changes.
Structured progress fields and final response lists are filtered to the image patterns in each individual request, so concurrently merged callers do not receive one another's image or restart-target lists. Detailed backend failure messages describe the shared operation and are returned to every affected caller.
With the Podman/systemd backend, reploy compares each running container's image ID with the ID returned by podman pull --quiet and restarts only systemd units whose running image differs. This also recovers a pulled-but-not-restarted image after an earlier deployment failure.
With the Docker Compose backend, Reploy groups changed services by Compose project and runs the equivalent of docker compose up -d --wait for the affected services. It does not use --no-deps, so Compose starts missing dependencies, follows depends_on ordering and conditions, and waits for services to be running or healthy. Already-running unchanged dependencies are normally left in place. Dependencies between separate Compose projects cannot be inferred or ordered automatically.
An off label prevents Reploy from pulling or directly targeting that service. Compose may still start or reconcile it when it is required as a dependency of an affected service; this is necessary for Compose dependency semantics to remain intact.
Compose recreation depends on the original project configuration remaining available. Keep the Compose files and referenced environment files at the paths recorded in the containers' Compose metadata, and run Reploy with the environment needed to reproduce any variable interpolation used when the project was created.
Reploy removes REPLOY_JWT_SECRET from every backend subprocess environment,
so it is never available for Compose interpolation. Other server environment
variables remain available where required; do not place unrelated secrets in
that environment when managing Compose projects.
Example units are in contrib/:
reploy.serviceruns the webhook/socket server.reploy-trigger.servicetriggers an update through the local socket.reploy.timerruns the trigger service periodically.docker-compose.example.yamlshows the Docker Compose opt-in and opt-out labels with a health-checked dependency.
Build or run the package from the flake:
nix build .#reploy
nix run .# -- --helpThe flake also exports a NixOS module:
{
inputs.reploy.url = "github:AveryanAlex/reploy";
outputs = { nixpkgs, reploy, ... }: {
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
reploy.nixosModules.default
({ ... }: {
services.reploy = {
enable = true;
jwtSecretFile = "/run/secrets/reploy-jwt-secret";
backend = "podman";
httpAddr = "127.0.0.1:8080";
socketPath = "/run/reploy.sock";
trigger = {
enable = true;
images = [ "*" ];
# Same systemd calendar syntax used by nix.gc.dates.
# This runs every day at 04:00.
dates = "04:00";
persistent = true;
};
};
})
];
};
};
}For Docker Compose, select the backend and label policy in the same module:
services.reploy = {
enable = true;
jwtSecretFile = "/run/secrets/reploy-jwt-secret";
backend = "docker-compose";
dockerComposePolicy = "opt-in";
};jwtSecretFile should point at a runtime secret file managed outside the Nix
store, for example by sops-nix or another NixOS secret manager. The module runs
the server as root and adds the selected backend CLI to the service PATH:
podman and systemctl for the default backend, or the Docker CLI with its
Compose plugin for Docker Compose. The JWT secret is required only when
enableHttp = true; a socket-only configuration can omit both jwtSecretFile
and environmentFile.
Do not place REPLOY_JWT_SECRET in extraEnvironment: Nix-generated service
configuration is not a secret store, and the module rejects that setting.
Reploy sets its Unix socket to mode 0600, and the server service also uses
UMask=0077, so the socket is root-only by default.
Registry authentication such as REGISTRY_AUTH_FILE can still be supplied with
environmentFile or extraEnvironment and remains available to every Podman
pull performed by the server.
The trigger timer uses systemd calendar expressions, matching the format of
nix.gc.dates. For example, dates = "04:00"; runs every day at 04:00,
dates = "weekly"; runs weekly, and dates = "*-*-* 04:00:00"; is the
explicit calendar form for every day at 04:00. Timer invocations require and run
after reploy.service, and use socket-only trigger mode without direct fallback.