A Go toolkit for managing Easypanel deployments — both a CLI for direct tRPC control and a gRPC server that exposes panel-wide deployment operations to authenticated clients.
┌─────────────────────────────────────────────────────┐
│ Clients (grpcurl, your app, SDKs) │
│ Auth: Bearer token in "authorization" metadata │
└──────────────────┬──────────────────────────────────┘
│ gRPC (port 50051)
┌──────────────────▼──────────────────────────────────┐
│ gRPC PaaS Server (cmd/grpc-server) │
│ • Auth interceptor (constant-time token compare) │
│ • 17 RPC methods │
│ • Compose port sanitization │
│ • Auto-domain provisioning │
└──────────────────┬──────────────────────────────────┘
│ HTTP POST (tRPC)
┌──────────────────▼──────────────────────────────────┐
│ Easypanel (76 tRPC routes) │
│ • Docker/Compose orchestration │
│ • Traefik reverse proxy + HTTPS │
│ • Let's Encrypt auto-certs │
└─────────────────────────────────────────────────────┘
- CLI — Direct Easypanel tRPC control (projects, services, domains, users, certs, settings)
- gRPC Server — Multi-tenant PaaS adapter with bearer token authentication
- Container Deployments — Deploy Docker images with auto-assigned domains
- Compose Stacks — Deploy docker-compose with per-service domain routing (ports auto-stripped for security), and replace the content of a live stack in place
- Domain Management — Default subdomains + custom domains with HTTPS
- Lifecycle Control — Start, stop, restart, scale, update env/resources
- Resource Limits — CPU and memory constraints per service
- Runtime Status — Live container state, image, and compose sub-services per service
- Version Pinned — Panel route surface pinned per Easypanel release, checked by tests and at startup
For complete copy-paste examples covering gRPC server setup, Docker images, inline Dockerfiles, Git/GitHub sources, Compose stacks, domains, logs, resources, lifecycle, and troubleshooting, see Deployment Guide.
- Go 1.25+
- Access to an Easypanel instance with API key
- (Optional)
protoc+protoc-gen-go+protoc-gen-go-grpcfor proto regeneration
git clone git@github.com:igun997/deploy-everything.git
cd deploy-everything
cp .env.example .env
# Edit .env with your credentials| Variable | Required | Description |
|---|---|---|
EASYPANEL_ENDPOINT |
Yes | Easypanel panel URL (e.g. https://panel.example.com) |
EASYPANEL_API_KEY |
Yes | Easypanel API key |
EASYPANEL_HTTP_TIMEOUT |
No | Panel request timeout as Go duration (default 5m; deploy calls block while images pull) |
GRPC_PORT |
No | gRPC listen port (default: 50051) |
GRPC_AUTH_TOKENS |
Yes* | Comma-separated bearer tokens for gRPC clients |
DEFAULT_DOMAIN |
Yes* | Easypanel wildcard domain (e.g. xyz.easypanel.host) |
*Required for gRPC server only.
# Build
go build -o deploy-everything .
# Load env
source .env
export EASYPANEL_ENDPOINT EASYPANEL_API_KEY./deploy-everything projects list
./deploy-everything projects inspect <project>
./deploy-everything projects create <project>
./deploy-everything projects destroy <project># Create services (types: app, mysql, mariadb, postgres, mongo, redis, compose)
./deploy-everything services create <project> <service> <type>
./deploy-everything services destroy <project> <service> <type>
# Docker image management
./deploy-everything services set-image <project> <service> <image>
./deploy-everything services deploy <project> <service> <type>
# Lifecycle
./deploy-everything services start <project> <service> <type>
./deploy-everything services stop <project> <service> <type>
./deploy-everything services restart <project> <service> <type>
./deploy-everything services inspect <project> <service> <type>
# Environment (saves config, then automatically redeploys app/compose)
./deploy-everything services set-env <project> <service> <type> "KEY1=val1" "KEY2=val2"
# App sources (all save, then automatically redeploy)
./deploy-everything services set-git <project> <service> <repo> <ref> [--path /]
./deploy-everything services set-github <project> <service> <owner> <repo> <ref> [--path /]
./deploy-everything services set-dockerfile <project> <service> '<Dockerfile content>'
# Compose sources (save, then automatically redeploy)
./deploy-everything services set-source-inline <project> <service> '<compose content>'
./deploy-everything services set-source-git-compose <project> <service> <repo> <ref> docker-compose.yml [--root-path /]
# Logs
./deploy-everything services logs <project> <service> [--type app|compose] [--compose-service web] [--limit 200]
# WordPress
./deploy-everything wordpress create <project> <service>
./deploy-everything wordpress deploy <project> <service>./deploy-everything domains list <project> <service>
./deploy-everything domains create <project> <service> <host> [--type app|compose] [--compose-service web] [--port 80] [--https]
./deploy-everything domains delete <project> <service> <type> <domain-id>
# Domain create/delete automatically redeploys the target app/compose service.
# Easypanel applies domain routing only while generating deployment config../deploy-everything whoami
./deploy-everything users list
./deploy-everything actions list
./deploy-everything certs list
./deploy-everything settings ip
./deploy-everything settings domain
./deploy-everything license infogo build -o grpc-server ./cmd/grpc-server/
./grpc-server
# Output: gRPC PaaS server listening on :50051All gRPC calls require the authorization metadata header with a valid token:
grpcurl -plaintext -H "authorization: your-token" \
localhost:50051 paas.PaaS/ListServicesTokens are configured via GRPC_AUTH_TOKENS (comma-separated). Tokens grant panel-wide API access; they are not scoped to individual projects. Use separate Easypanel instances or add an authorization layer when strict tenant isolation is required.
| Method | Description |
|---|---|
DeployContainer |
Deploy Docker image → create service + set image + env + deploy + auto-domain |
DeployCompose |
Create and deploy a docker-compose stack (strips ports:, routes via composeService). Fails if the service already exists |
UpdateComposeSource |
Replace the compose content of an existing compose service, then redeploy. Set skip_deploy to store without deploying |
DestroyService |
Remove service and all associated domains |
GetServiceStatus |
Service config (name, type, image, env, domains, deploy URL) plus runtime state (status, running containers, compose sub-services). Env values are redacted unless include_env is set |
ListServices |
All services in a project |
AddDomain |
Attach domain to service (supports compose sub-services) |
RemoveDomain |
Detach domain by ID |
ListDomains |
List all domains for a service |
UpdateEnv |
Set environment variables |
RestartService |
Restart service |
StopService |
Stop service |
StartService |
Start service |
GetLogs |
Fetch app/compose logs with structured entries and flat text. Requires panel log aggregation (see Logs) |
ScaleService |
Set replica count |
UpdateResources |
Set CPU/memory limits |
UpdateDeploy |
Set app replicas, zero-downtime behavior, and command |
grpcurl -plaintext -H "authorization: your-token" -d '{
"project": "myproject",
"service": "webapp",
"image": "nginx:alpine",
"env": {"NODE_ENV": "production"},
"port": 80
}' localhost:50051 paas.PaaS/DeployContainerResponse:
{
"serviceId": "myproject/webapp",
"defaultDomain": "myproject-webapp.xyz.easypanel.host",
"deployUrl": "http://...",
"status": "deploying"
}grpcurl -plaintext -H "authorization: your-token" -d '{
"project": "myproject",
"service": "stack",
"composeContent": "services:\n web:\n image: wordpress:6\n expose:\n - \"80\"\n db:\n image: mysql:8\n environment:\n MYSQL_ROOT_PASSWORD: secret\n",
"domains": [
{"composeService": "web", "port": 80}
]
}' localhost:50051 paas.PaaS/DeployComposeDeployCompose creates: it calls the panel's createService first, so it fails with
create compose: [BAD_REQUEST] Service already exists. on a service that is already
there. Use UpdateComposeSource to replace the content of a live stack.
grpcurl -plaintext -H "authorization: your-token" -d '{
"project": "myproject",
"service": "stack",
"composeContent": "services:\n web:\n image: wordpress:6.5\n expose:\n - \"80\"\n"
}' localhost:50051 paas.PaaS/UpdateComposeSource{
"deployed": true,
"status": "deploying"
}The content fully replaces what was stored, and goes through the same gate as
DeployCompose: invalid YAML and network_mode: host are rejected, host-published
ports: are stripped, expose: is kept. Set "skipDeploy": true to store the
content without redeploying, then deploy once after a batch of edits. A service that
does not exist, or exists under another type, returns NotFound.
grpcurl -plaintext -H "authorization: your-token" -d '{
"project": "myproject",
"service": "webapp",
"host": "app.yourdomain.com",
"port": 80,
"https": true
}' localhost:50051 paas.PaaS/AddDomaingrpcurl -plaintext -H "authorization: your-token" -d '{
"project": "myproject",
"service": "webapp",
"serviceType": "app",
"replicas": 3
}' localhost:50051 paas.PaaS/ScaleService.
├── main.go # CLI entrypoint
├── cmd/
│ ├── root.go # Cobra root command
│ ├── panel_client.go # Direct Easypanel tRPC HTTP client
│ ├── projects.go # CLI: project commands
│ ├── services.go # CLI: service commands (app/db/compose)
│ ├── domains.go # CLI: domain commands
│ ├── actions.go # CLI: deployment actions
│ ├── users.go # CLI: user management
│ ├── settings.go # CLI: panel settings
│ ├── certs.go # CLI: certificates
│ ├── license.go # CLI: license info
│ ├── wordpress.go # CLI: WordPress shortcuts
│ ├── whoami.go # CLI: auth check
│ ├── version.go # CLI: version command (ldflags metadata)
│ └── grpc-server/
│ └── main.go # gRPC server entrypoint
├── internal/
│ ├── auth/
│ │ └── interceptor.go # gRPC auth interceptor (unary + stream)
│ ├── easypanel/
│ │ ├── client.go # Reusable Easypanel HTTP client
│ │ ├── routes.go # Every panel route this project calls
│ │ ├── version.go # Panel version probe + supported range + log settings
│ │ └── testdata/ # Pinned panel route surfaces (2.32.2 / 2.33.0 / 2.33.1)
│ ├── server/
│ │ ├── paas.go # PaaS gRPC service implementation
│ │ └── logs.go # Panel log response flattening
│ └── version/
│ └── version.go # Build metadata injected via -ldflags
├── proto/
│ ├── paas.proto # Protobuf service definition
│ ├── paas.pb.go # Generated message types
│ └── paas_grpc.pb.go # Generated gRPC stubs
├── .github/workflows/
│ ├── ci.yml # PR checks: fmt, vet, test, build, image build
│ └── release.yml # master -> :dev images, tags -> release + binaries
├── scripts/
│ ├── panel-surface.sh # Re-pin panel route surfaces from official images
│ └── extract-panel-surface.py # Route/input extractor for the panel bundle
├── Dockerfile # Multi-stage, multi-arch (targets: server, cli)
├── Makefile # build / test / cross / docker targets
├── .dockerignore
├── grpc_e2e_test.go # E2E tests (live panel)
├── CHANGELOG.md # Release notes per version
├── .env.example # Environment template
├── .gitignore
├── go.mod
└── go.sum
Services automatically get a subdomain: {project}-{service}.{DEFAULT_DOMAIN}
For compose services: {project}-{service}-{composeService}.{DEFAULT_DOMAIN}
Point a wildcard DNS (*.apps.example.com) to your Easypanel server IP, then attach domains via AddDomain.
HTTPS is auto-provisioned via Let's Encrypt through Easypanel's Traefik.
The gRPC server automatically strips ports: directives from compose content. Only expose: is allowed — Traefik handles external routing. This prevents containers from binding directly to host ports.
GetServiceStatus merges the panel's stored configuration with the live Docker
view, so a single call answers both "how is it configured" and "is it up":
The panel reports only running containers, so an empty list means stopped. If the
panel refuses the container query the RPC still returns the configuration with
status: "unknown" instead of failing. Compose services have no configured image,
so image falls back to the running container's image.
Easypanel stores secrets in the same env blob its inspect routes return, and a
gRPC token is panel-wide: any caller can read any project. GetServiceStatus
therefore returns keys with <redacted> values. Set include_env: true to get
clear text:
grpcurl -plaintext -H "authorization: Bearer $TOKEN" \
-d '{"project":"demo","service":"api","serviceType":"app","includeEnv":true}' \
localhost:50051 paas.PaaS/GetServiceStatus| Version | |
|---|---|
| Minimum supported | 2.32.0 |
| Newest tested | 2.33.1 |
The panel's route surface is pinned in internal/easypanel/testdata/panel-surface-*.json,
extracted from the official easypanel/easypanel images. Every route this project
calls is listed in internal/easypanel/routes.go, and
TestRequiredRoutesExistInEverySupportedPanel asserts each one exists in every
pinned release. Both bounds of the window are backed by a pinned surface: 2.32.0,
2.32.2, 2.33.0 and 2.33.1 expose the same routes with identical input fields
(374 procedures in 2.32.0, 375 from 2.32.2 on, the addition being unrelated to
this project), which is why one code path covers the range.
The gRPC server probes the live panel version at startup (GET /api/openapi.json,
the one version route present in all supported releases) and logs whether it falls
inside that window. Set EASYPANEL_SKIP_VERSION_CHECK=1 to skip the probe.
# Which panel am I talking to?
deploy-everything settings panel-version
# Re-pin the route surface after a panel upgrade (needs docker + python3)
scripts/panel-surface.sh 2.32.0 2.32.2 2.33.0 2.33.1
go test ./internal/easypanel/ -run TestRequiredRoutes -vAfter adding a newer release, bump MaxTestedVersion in
internal/easypanel/version.go to match the newest pinned file. MinSupportedVersion
must equal the oldest pinned file; the tests enforce both.
Easypanel serves service logs from its own Loki deployment and has no other log
source: no docker logs route exists in the panel API. Loki and Promtail are only
deployed when log aggregation is enabled under Settings -> Logs, which the panel
gates behind a license with advanced monitoring.
When aggregation is off, the panel answers log queries with an opaque
[BAD_REQUEST] fetch failed. GetLogs prechecks logs.getSettings and returns
FailedPrecondition with the reason instead:
panel log aggregation is disabled, so Loki is not deployed and the panel has no
log source. Enable it in the Easypanel UI under Settings -> Logs (requires a
license with advanced monitoring), then retry GetLogs.
Check the current state with:
deploy-everything settings logsGetLogs filters map straight onto the panel's Loki query: stream
(stdout/stderr), levels, search, start, end, and limit (clamped to the
panel's maximum of 1000). Supplying start switches the query to oldest-first.
# Unit tests (no panel needed): route surface, version parsing, log/status mapping
go test ./internal/... ./cmd/... -count=1
# Run E2E tests (requires live Easypanel instance)
go test -v -run "TestGRPC_" -timeout 180s
# Individual tests
go test -v -run TestGRPC_AuthReject -timeout 30s
go test -v -run TestGRPC_DeployContainerE2E -timeout 120s
go test -v -run TestGRPC_DeployComposeE2E -timeout 120s
go test -v -run TestGRPC_UpdateResources -timeout 60s
go test -v -run TestGRPC_RuntimeStatusAndLogs -timeout 120sTests create real services on the panel, verify HTTP reachability, and clean up after themselves.
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/paas.protomake help # list targets
make build # both binaries -> dist/ (version metadata injected)
make build-cross # linux/darwin/windows, amd64+arm64 -> dist/
make test # go test ./... (integration tests skip without EASYPANEL_* env)
make vet fmt-check # static checks, same as CI
make docker # local server image -> ghcr.io/igun997/deploy-everything:dev
make docker-cli # local CLI image -> ghcr.io/igun997/deploy-everything-cli:dev
make docker-buildx PUSH=1 # multi-arch (linux/amd64+arm64) build and pushVersion metadata is injected at link time into internal/version:
./dist/deploy-everything version
# v1.2.3 (commit a1b2c3d, built 2026-01-01T00:00:00Z, linux/amd64, go1.25.0)
./dist/grpc-server versionOne Dockerfile, two final stages, both distroless + static (CGO_ENABLED=0), running as nonroot:
| Stage | Image | Entrypoint |
|---|---|---|
server (default) |
ghcr.io/igun997/deploy-everything |
grpc-server (port 50051; CLI also included) |
cli |
ghcr.io/igun997/deploy-everything-cli |
deploy-everything |
# Run the gRPC server
docker run --rm -p 50051:50051 --env-file .env \
ghcr.io/igun997/deploy-everything:0.2.0
# Run a CLI command
docker run --rm --env-file .env \
ghcr.io/igun997/deploy-everything-cli:0.2.0 projects list
# Bleeding edge (current master)
docker run --rm --env-file .env \
ghcr.io/igun997/deploy-everything-cli:dev version| Workflow | Trigger | Does |
|---|---|---|
.github/workflows/ci.yml |
pull request → master |
gofmt check, go vet, build both binaries, go test, build both images (no push) + version smoke test |
.github/workflows/release.yml |
push to master, tag v* |
verify → multi-arch image build/push to GHCR (+ SBOM, provenance attestation); on tags also cross-compiled archives attached to a GitHub Release |
| Git event | Image tags |
|---|---|
push/merge to master |
dev, dev-<short-sha> |
tag v0.2.0 |
0.2.0, 0.2, 0, latest |
tag v1.2.3 |
1.2.3, 1.2, 1, latest |
pre-release tag v1.2.3-rc1 |
1.2.3-rc1 only (no latest, no 1.2/1) |
So :dev always points at the current master, :latest always points at the newest stable release.
The leading v is stripped from image tags: git tag v0.2.0 produces image tag 0.2.0. The binaries still report the full v0.2.0 from version.
git tag -a v1.2.3 -m "v1.2.3"
git push origin v1.2.3That publishes ghcr.io/igun997/deploy-everything:1.2.3 (+ 1.2, 1, latest), the matching -cli image, and a GitHub Release with tar.gz/zip archives plus checksums.txt for linux/darwin (amd64+arm64) and windows/amd64.
No secrets needed — GHCR push uses the built-in GITHUB_TOKEN. Packages inherit repo visibility on first publish; make them public in the repo's package settings if desired.
MIT
{ "name": "hermes-a1", "type": "compose", "enabled": true, "image": "nousresearch/hermes-agent:v2026.8.3", "env": "API_KEY=<redacted>", // values hidden unless include_env is set "domains": ["pods-hermes-a1-agent.cv911b.easypanel.host"], "status": "running", // running | stopped | unknown "runningContainers": 1, "containers": [{ "id": "21bb7f989f0f", "name": "pods_hermes-a1-agent-1", "image": "nousresearch/hermes-agent:v2026.8.3", "state": "running", "status": "Up 47 hours" }], "composeServices": ["agent"] // compose only }