Open source v3 · Go · S3-compatible

Your own CDN, anywhere you want.

Upload packages and serve static assets via a simple REST API, backed by any S3-compatible storage you already own or trust.

# 1. Get a token
curl -X POST https://cdn.example.com/tokens \
  -u admin:password \
  -H 'Content-Type: application/json' \
  -d '{"name": "ci", "expirationDate": "2027-12-31"}'

# 2. Upload your library
# A lone top-level folder in the ZIP is stripped — see the API reference
curl -X POST https://cdn.example.com/packages \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@my-lib.zip" \
  -F "name=my-lib" \
  -F "version=2.1.0"
  • Any S3 backend

    AWS S3, Garage, R2, OVH, Scaleway — plug in the storage you already use or pay for.

  • Opaque token uploads

    Cryptographically random opaque tokens protect write endpoints. Public asset serving needs no authentication — CORS configurable.

  • Semver aliases

    pkg@1.0 resolves to the latest stable 1.0.x, while pkg@latest resolves to the highest stable version. Pin with pkg@1.0.3 for immutability.

  • Two-level cache

    HTTP Cache-Control headers out of the box, plus optional Redis/Valkey for internal version resolution.

  • Prometheus metrics

    HTTP traffic, S3 latency, cache hits and package lifecycle — all exposed on /metrics.

  • Docker & Kubernetes ready

    Pre-built image, Compose stacks with Garage + monitoring, Kubernetes manifests and Helm chart included.

Architecture

Gimme is a thin, stateless Go service. It sits between your clients and an S3-compatible object store, handling authentication, semver resolution, and streaming. A reverse proxy in front is recommended for production.

Browser / CLI
Nginx / CDN optional
gimme :8080
Redis / Valkey optional (tokens + cache)
S3 storage

Package naming convention

Objects are stored in S3 as <package>@<version>/<file>:

my-lib@2.1.0/my-lib.min.js
my-lib@2.1.0/my-lib.min.css
my-lib@2.1.1/my-lib.min.js   ← next patch

Semver partial resolution

A request for my-lib@2.1/file.js lists the package's versions in S3 (one entry per version, not per file) and serves the file from the highest matching stable version. my-lib@latest/file.js uses the same path to resolve the highest stable version. If that version does not contain the file, the request returns 404. With Redis enabled, the resolved path is cached — S3 is only hit on a cache miss.

Quickstart

Garage is a lightweight open-source S3 store. The stack provisions itself — no manual bucket or key setup needed.

git clone https://github.com/ziggornif/gimme
cd gimme/examples/deployment/docker-compose/with-garage
docker compose up -d

Gimme starts at http://localhost:8080. Prometheus and Grafana are included. An optional Valkey cache is pre-configured — just enable it in gimme.yml.

The init-garage service creates the bucket and writes the configuration automatically on first run.

Your first upload in 3 steps

Create an access token

curl -s -X POST http://localhost:8080/tokens \
  -u gimmeadmin:gimmeadmin \
  -H 'Content-Type: application/json' \
  -d '{"name": "my-token", "expirationDate": "2027-12-31"}'
Without expirationDate, the token expires in 90 days.

Upload a ZIP package

# A lone top-level folder in the ZIP is stripped — see the API reference
curl -X POST http://localhost:8080/packages \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@my-lib.zip" \
  -F "name=my-lib" \
  -F "version=1.0.0"
# → 201 Created

Or publish from CI

The upload Action downloads gimme-cli, archives the contents of the selected directory, and publishes them. Thus dist/app.js becomes app.js, not dist/app.js.

- uses: ziggornif/gimme/upload-action@v3
  with:
    url: https://cdn.example.com
    token: ${{ secrets.GIMME_TOKEN }}
    path: dist
    name: awesome-lib
    version: ${{ github.ref_name }} # optional
    cli-version: latest            # optional

For direct use, install gimme-cli from the platform assets attached to a GitHub Release. The token environment variable is recommended because a token flag is visible in the process list.

export GIMME_URL=https://cdn.example.com
export GIMME_TOKEN=...
gimme-cli push ./dist --name awesome-lib --version 1.2.0

Without --version, the CLI checks the current directory's package.json, a GitHub Actions tag, then the exact Git tag at HEAD. One leading v is removed. gimme-cli is attached to releases from v3.0.0 on.

Inputs are url, token, path, and name (required), plus version (automatic when empty), cli-version (default latest), and download-base-url (testing only). The Action runs on Linux and macOS runners; on Windows, use gimme-cli directly.

Use your assets

<script src="http://localhost:8080/gimme/my-lib@1.0.0/my-lib.min.js"></script>
Assets are served from /gimme/my-lib@1.0.0/<file> with full CORS support.

Configuration

Config is loaded from gimme.yml (local directory or /config/gimme.yml inside Docker). Every value can be overridden by an environment variable — Viper maps them automatically with the GIMME_ prefix (e.g. GIMME_S3_URL, GIMME_ADMIN_USER).

admin:
  user: gimmeadmin
  password: gimmeadmin
port: 8080
secret: your-secret-at-least-32-chars-long
s3:
  url: your.s3.endpoint
  key: your-access-key
  secret: your-secret-key
  bucketName: gimme
  location: garage       # region as defined by your backend
  ssl: false             # set false for local Garage
metrics: true            # expose /metrics (Prometheus)

# Token store: "file" (default), "redis", or "postgres"
# tokenStore:
#   mode: file           # "file" | "redis" | "postgres"
#   pg_url: postgres://gimme:password@localhost:5432/gimme?sslmode=disable

# Redis — required when tokenStore.mode is "redis" or cache.enabled is true
# redis_url: redis://localhost:6379

# cache:
#   enabled: false       # optional version-resolution cache
#   type: redis
#   ttl: 3600
#   file_path: /tmp/gimme-tokens.enc  # used only when tokenStore.mode is "file"
Key Description Default
secret Master secret (min 32 bytes) required
admin.user Admin username (Basic Auth) required if basic
admin.password Admin password (Basic Auth) required if basic
port HTTP server port 8080
s3.url S3 / Garage endpoint URL required
s3.key S3 access key required
s3.secret S3 secret key required
s3.bucketName S3 bucket name gimme
s3.location S3 region / Garage zone required
s3.ssl Enable TLS for S3 connection true
metrics Enable /metrics Prometheus endpoint true
cache.enabled Enable internal Redis cache false
compression.enabled Generate Brotli and gzip variants during upload true
cache.type Cache backend type redis
cache.ttl Cache entry TTL in seconds 3600
redis_url Redis / Valkey connection URL. Required when tokenStore.mode is redis or cache.enabled is true. ""
cache.file_path Path to the encrypted token file used when tokenStore.mode is file /tmp/gimme-tokens.enc
tokenStore.mode Token persistence backend. file stores tokens in an encrypted local file (no external dependency). redis stores tokens in Redis — requires redis_url. postgres stores tokens in PostgreSQL — requires tokenStore.pg_url. file
tokenStore.pg_url PostgreSQL connection URL. Required when tokenStore.mode is postgres. ""
auth.mode Admin auth mode (basic or oidc) basic
auth.oidc.issuer OIDC issuer URL required if oidc
auth.oidc.client_id OIDC client ID required if oidc
auth.oidc.client_secret OIDC client secret optional
auth.oidc.redirect_url OIDC redirect URI required if oidc
auth.oidc.secure_cookies Use Secure flag on session cookies (disable only for local HTTP dev) true
cors.allowed_origins List of allowed CORS origins. Defaults to all origins (*) if empty. [] (all origins)
upload.max_size Maximum upload request body size; accepts bytes or a size such as 100MB (base 1024) 100MB
upload.max_entries Maximum number of file entries in a ZIP archive 10000
upload.max_uncompressed_size Maximum cumulative declared decompressed size; accepts bytes or a size such as 500MB (base 1024) 500MB
token_file Path of the encrypted token file, used only when tokenStore.mode is file /tmp/gimme-tokens.enc
Token store mode. By default (tokenStore.mode: file), tokens are persisted to an encrypted local file — no external dependency needed. Set tokenStore.mode: redis and provide redis_url to share tokens across multiple instances. Set tokenStore.mode: postgres and provide tokenStore.pg_url for deployments that already have a PostgreSQL database.
secret must be at least 32 bytes long. Gimme will refuse to start with a shorter value.

Deployment

  • Docker Compose + Garage

    Zero-setup stack with self-hosted Garage storage, Valkey cache and Prometheus/Grafana monitoring bundled in.

    View on GitHub
  • Docker Compose + managed S3

    Connect to any managed provider (AWS S3, OVH, Scaleway, Cellar, R2). Monitoring included.

    View on GitHub
  • Kubernetes

    Namespace, Deployment, Service and Ingress manifests. Liveness and readiness probes pre-configured.

    View on GitHub
  • Helm chart

    Production-ready Helm chart published on GHCR. Secrets, Ingress, HPA and Redis cache configurable via values.yaml.

    View on GitHub
  • systemd

    Run as a Linux system service with automatic restarts and environment file support.

    View on GitHub

Helm chart

The Helm chart is published on GHCR (OCI) and provides a production-ready deployment with credentials in a dedicated Kubernetes Secret, optional Ingress with TLS, HPA and Redis cache.

Install

helm install gimme oci://ghcr.io/ziggornif/charts/gimme \
  --namespace gimme \
  --create-namespace \
  --set credentials.secret=<signing-secret-min-32-chars> \
  --set credentials.admin.user=<admin-user> \
  --set credentials.admin.password=<admin-password> \
  --set credentials.s3.key=<s3-key> \
  --set credentials.s3.secret=<s3-secret> \
  --set s3.url=<s3-endpoint-url>

Or with a values.yaml file:

helm install gimme oci://ghcr.io/ziggornif/charts/gimme \
  --namespace gimme --create-namespace \
  -f my-values.yaml

Minimal values.yaml

credentials:
  secret: "your-secret-at-least-32-chars-long"
  admin:
    user: "admin"
    password: "strongpassword"
  s3:
    key: "your-s3-access-key"
    secret: "your-s3-secret-key"

s3:
  url: "https://s3.example.com"
  bucketName: gimme
  location: eu-west-1
  ssl: true

Enable Ingress with TLS

ingress:
  enabled: true
  className: nginx
  host: cdn.example.com
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  tls:
    enabled: true
    secretName: gimme-tls

Enable Redis cache

cache:
  enabled: true
  type: redis
  ttl: 3600
  redisUrl: redis://my-redis-service:6379

Enable HPA (auto-scaling)

hpa:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

Upgrade / Uninstall

# Upgrade
helm upgrade gimme oci://ghcr.io/ziggornif/charts/gimme \
  --namespace gimme -f my-values.yaml

# Uninstall
helm uninstall gimme --namespace gimme

Single Docker container

docker run -p 8080:8080 \
  -v "$(pwd)/gimme.yml:/config/gimme.yml" \
  ziggornif/gimme:latest

Health probes

Endpoint Type Condition
GET /healthz Liveness Process is alive
GET /readyz Readiness S3 bucket is reachable

API Reference

Method Route Auth Description
GET / — HTML homepage (ReDoc API docs)
GET /admin Admin auth Admin UI (token management)
POST /tokens Admin auth Create an opaque access token
DELETE /tokens/:id Admin auth Revoke an access token
POST /packages Bearer token Upload a ZIP package
DELETE /packages/:package Bearer token Delete a package (name@version)
GET /gimme/:package — List a package's versions (name) or a version's files (name@version), HTML or JSON
GET /gimme/:package/*file — Serve a static file
HEAD /gimme/:package/*file — Read file headers without its body
GET /metrics — Prometheus / OpenMetrics endpoint
GET /docs — Interactive Swagger / ReDoc docs
GET /healthz — Liveness probe — process alive
GET /readyz — Readiness probe — S3 bucket reachable

Create a token

Breaking change: Tokens are now opaque strings (gim_<hex>). JWT tokens from previous versions are invalid — regenerate via /admin or POST /tokens. The raw token is returned once and never stored again.
curl -s -X POST http://localhost:8080/tokens \
  -u gimmeadmin:gimmeadmin \
  -H 'Content-Type: application/json' \
  -d '{"name": "my-token", "expirationDate": "2027-12-31"}'
{"id": "550e8400-...", "name": "my-token", "token": "gim_4a7b9c2d1e3f...", "createdAt": "...", "expiresAt": "..."}

Upload a package

Package names may contain only letters, digits, ., _, and -. Versions must be full semver versions such as 1.0.0; prerelease and build metadata are accepted.

The paths inside the archive become the URLs, with one exception: a single top-level folder is stripped. So zip -r my-lib.zip my-lib/ and cd my-lib && zip -r ../my-lib.zip . produce the same URLs. Several top-level folders are kept as they are, so zip -r my-lib.zip dist/ src/ serves /gimme/my-lib@1.0.0/dist/... — rarely what you want.

curl -X POST http://localhost:8080/packages \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@my-lib.zip" \
  -F "name=my-lib" \
  -F "version=1.0.0"
# → 201 Created

Uploads are bounded by request body size, ZIP entry count, and cumulative declared decompressed size, configured by the three upload.* keys above. Exceeding any configured limit answers 413 Payload Too Large with an error naming the limit that was exceeded.

A name@version that already exists answers 409 Conflict; gimme never overwrites a published version. Delete it first if you mean to replace it.

A failed upload is rolled back — the package either lands entirely or leaves nothing behind, so the same name@version can be retried immediately. If the rollback itself fails, the 500 says so and names the DELETE /packages/<pkg>@<version> you should run before retrying. A server killed outright (kill -9, OOM) gets no chance to roll back and leaves a partial package that answers 409 on retry; remove it with the same DELETE.

macOS metadata is never published: everything under a top-level __MACOSX/, and any .DS_Store at any depth. Neither counts when gimme looks for the wrapper folder, so an archive zipped from the Finder behaves like any other. Entry names are normalised to Unicode NFC — macOS stores accented names decomposed while browsers request them composed, so without this a file like café.js would be stored under a key no URL could reach. Two entries differing only by that encoding are reported as the collision they are.

An archive is rejected entirely, with nothing uploaded, when an entry escapes the package namespace (../, absolute paths, empty names) or when two entries map to the same URL.

Excluding files with .gimmeignore

Put a .gimmeignore in the archive to keep files out of the package. It uses .gitignore syntax and is looked up in exactly two places: the archive root, or — when the archive wraps everything in a single top-level folder — inside that folder. Both ways of building the archive therefore work:

cd dist && zip -r ../pkg.zip .   # .gimmeignore at the archive root
zip -r pkg.zip dist              # .gimmeignore at dist/.gimmeignore

Patterns are written against the contents of your package, not against the archive, so the same file works either way.

*.map
node_modules/
/internal-notes.md
!keep/important.js

The .gimmeignore itself is not published. One found anywhere else in the tree is an ordinary file — uploaded, with no effect. An excluded file does not count when gimme looks for the wrapper folder, which is how you publish a dist/ folder alongside files you do not want served.

A .gitignore is not honoured, deliberately: it usually lists dist/, so applying it would drop the very files you meant to publish.

An archive whose entries are all excluded is rejected, like an empty one. Exclusions do not relax the upload limits — those apply to the archive as sent.

Serve a file

# Exact version — cached for 1 year
curl http://localhost:8080/gimme/my-lib@1.0.0/my-lib.min.js

# Partial alias — resolves to latest 1.0.x, cached 5 minutes
curl http://localhost:8080/gimme/my-lib@1.0/my-lib.min.js

# Latest alias — resolves to highest stable version, cached 5 minutes
curl http://localhost:8080/gimme/my-lib@latest/my-lib.min.js

# Read the SHA-384 SRI token without downloading the body
curl -I http://localhost:8080/gimme/my-lib@1.0.0/my-lib.min.js

Browse

curl -H 'Accept: application/json' http://localhost:8080/gimme/my-lib
{"package":"my-lib","versions":["1.10.0","1.9.0","1.2.0"]}

Versions are returned in semver descending order, newest first. The same URL returns HTML to a browser and JSON under Accept: application/json. An unknown package answers 404.

Add a version to list its files. Listings use keyset pagination: ?limit=N defaults to 50 and is clamped to the range 1–500; an absent or non-numeric value falls back to 50. The ?after=<raw object key> cursor is the full S3 object key of the last item on the previous page.

curl -i -H 'Accept: application/json' \
  'http://localhost:8080/gimme/my-lib@1.0.0?limit=50'
Link: </gimme/my-lib@1.0.0?limit=50>; rel="first", </gimme/my-lib@1.0.0?limit=50&after=my-lib%401.0.0%2Fapp.js>; rel="next"
{
  "package": "my-lib",
  "version": "1.0.0",
  "files": [
    {"name": "my-lib@1.0.0/app.js", "size": 1234}
  ],
  "pagination": {"limit": 50, "next": "my-lib@1.0.0/app.js", "has_more": true, "total": null}
}

The Link response header carries rel="first" and, when another page exists, rel="next". The deliberate absence of rel="prev" and rel="last" is a property of keyset pagination: neither is derivable from its cursor, so the browser's Back button is Previous. A browser's Accept, and a bare */*, still receive HTML.

Each file's name is its full object key. total is the count when the whole listing fits on one page and null otherwise. S3 exposes no object count for a prefix; a counting pass would re-drain the listing that pagination exists to avoid.

Newly uploaded files expose their Subresource Integrity token through Gimme-Integrity. The package listing can copy ready-to-use JavaScript and CSS snippets; legacy objects remain available without the header.

The generated crossorigin="anonymous" attribute requires an Access-Control-Allow-Origin response header. Gimme defaults to *, so this works out of the box. Setting cors.allowed_origins to a fixed list silently breaks SRI for cross-origin consumers whose origins are not included.

Delete a package

curl -X DELETE http://localhost:8080/packages/my-lib@1.0.0 \
  -H "Authorization: Bearer $TOKEN"
# → 204 No Content

Only that exact version is deleted. Versions must be full semver; a partial or invalid version returns 400 Bad Request.

Caching

Two independent caching levels that compose naturally:

Browser
Level 1 Nginx / CDN HTTP Cache-Control
Level 2 gimme + Redis version resolution
S3

Level 1 — HTTP Cache-Control zero dependency

Gimme emits Cache-Control headers on every response. Any HTTP cache honours them automatically.

Compressible files are stored with Brotli and gzip variants during upload. Requests negotiate them with Accept-Encoding, and responses emit Content-Encoding: br or Content-Encoding: gzip alongside Vary: Accept-Encoding so browsers and intermediate caches keep each representation separate. Missing variants fall back to the identity object; older packages must be re-uploaded to gain compressed variants.

If a client refuses the identity encoding and no stored variant matches an encoding it accepts, gimme answers 406 Not Acceptable with no acceptable content coding available for this file. This is the correct HTTP response, not an error condition to work around.

Version type Example Cache-Control
Pinned (3-part semver) pkg@1.0.0 public, max-age=31536000, immutable
Partial alias pkg@1.0 or pkg@1 public, max-age=300
Not found (404) any no-store

Every file response also carries ETag and Last-Modified. Gimme answers 304 Not Modified when If-None-Match matches, or when If-Modified-Since matches and If-None-Match is absent. Revalidating a partial version therefore transfers only headers when the file has not changed, instead of downloading the body again.

Level 2 — Internal Redis cache optional

When enabled, the resolved S3 path for partial versions is stored in Redis, skipping the package version listing (one entry per version, not per file) on repeat requests. File bodies are always streamed directly from S3 — only the path lookup is cached.

redis_url: redis://localhost:6379
cache:
  enabled: true
  type: redis
  ttl: 3600

Cache entries are invalidated when a package is deleted via DELETE /packages/:package. Partial-version entries expire naturally via TTL.

Monitoring

Every instance exposes a /metrics endpoint in OpenMetrics format. In addition to standard Go runtime metrics, gimme tracks:

Metric Type Description
gimme_http_requests_total Counter HTTP requests by route, method and status code
gimme_s3_operation_duration_seconds Histogram S3 operation latency by operation name
gimme_cache_hits_total Counter Cache hits on partial-version resolution
gimme_cache_misses_total Counter Cache misses on partial-version resolution
gimme_packages_uploaded_total Counter Packages successfully uploaded
gimme_packages_deleted_total Counter Packages successfully deleted

A pre-configured Prometheus + Grafana stack ships with both Docker Compose examples. Once running: Prometheus at :9090, Grafana at :3000.