A self-hosted CDN solution written in Go.
Upload ZIP packages and serve static assets (JS, CSS, images, …) via a simple REST API, backed by any S3-compatible object storage — AWS S3, Google Cloud Storage, OVH Object Storage, Scaleway Object Storage, Clever Cloud Cellar, Garage, Minio, and more.
💡 A caching layer in front of gimme (e.g. Nginx, Varnish, Cloudflare) is strongly recommended for production use.
- Documentation
- Architecture
- Quick Start
- Configuration
- API Usage
- Deployment Examples
- Caching Strategy
- Monitoring
The Gimme documentation is available at https://ziggornif.github.io/gimme/.
graph LR
Client["Client\n(browser / curl)"]
Gimme["gimme\n:8080"]
S3["Any S3-compatible storage\n(AWS S3, OVH, Cellar, Garage, …)"]
Cache["Cache layer\n(Nginx / CDN) — optional"]
Redis["Redis / Valkey\n(optional — tokens + cache)"]
Client -->|"GET /gimme/pkg@1.0/file.js"| Cache
Cache -->|cache miss| Gimme
Gimme -.->|"version resolution (partial)"| Redis
Gimme -->|stream object| S3
Client -->|"POST /packages (Bearer token)"| Gimme
Gimme -->|store objects| S3
sequenceDiagram
participant Dev as Developer
participant API as gimme API
participant Val as Archive Validator
participant S3 as Object Storage
Dev->>API: POST /packages (Bearer token, multipart ZIP)
API->>Val: Validate ZIP (application/zip or application/octet-stream)
Val-->>API: OK
API->>S3: PutObject pkg@version/file (parallel goroutines)
S3-->>API: 200 OK
API-->>Dev: 201 Created
sequenceDiagram
participant Browser
participant API as gimme API
participant S3 as Object Storage
Browser->>API: GET /gimme/awesome-lib@1.0.0/awesome-lib.min.js
Note over API: Semver partial match: 1.0 → latest 1.0.x
API->>S3: GetObject awesome-lib@1.0.3/awesome-lib.min.js
S3-->>API: stream
API-->>Browser: 200 OK (Content-Type: application/javascript)
Gimme works with any S3-compatible provider. Just fill in gimme.yml with your credentials:
s3:
url: s3.amazonaws.com # or s3.fr-par.scw.cloud, s3.gra.io.cloud.ovh.net, cellar-c2.services.clever-cloud.com, …
key: your-access-key
secret: your-secret-key
bucketName: gimme
location: eu-west-1 # region as defined by your provider
ssl: trueSee with-managed-s3/ for a ready-to-use Docker Compose example with monitoring included.
If you also want to self-host the object storage, Garage is a lightweight S3-compatible store that runs alongside gimme. The stack provisions itself automatically — no manual setup needed.
cd examples/deployment/docker-compose/with-garage
docker compose up -dGimme will be available at http://localhost:8080.
The init-garage service creates the bucket and writes the config automatically.
See with-garage/README.md for configuration details.
Requires Go 1.26+ and a running S3-compatible backend.
cp gimme.example.yml gimme.yml
# Edit gimme.yml with your S3 credentials
make build && ./dist/gimme
make buildcompiles a native binary for your current OS/architecture. Usemake releaseto produce a Linux/amd64 binary withupxcompression (used by Docker and CI).
Configuration is read from gimme.yml (local directory or /config/gimme.yml in Docker).
Environment variables override file values automatically (via Viper).
The config file is optional: when every required value is supplied through the environment, Gimme starts without one. See Environment variables below.
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 # use region name matching your backend
ssl: false # default is true; set false for local backends (Garage, dev)
# metrics: true # optional — expose /metrics (Prometheus), defaults to true
# Token store: "file" (default, no external dependency), "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"
# upload:
# max_size: 100MB # request body size
# max_entries: 10000 # ZIP file entries
# max_uncompressed_size: 500MB # decompressed total size| Key | Description | Default |
|---|---|---|
secret |
Token signing secret (min 32 chars) | required |
admin.user |
Admin username (Basic Auth) | required |
admin.password |
Admin password (Basic Auth) | required |
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 |
Bucket name | gimme |
s3.location |
S3 region / Garage zone | required |
s3.ssl |
Enable TLS for S3 connection | true |
metrics |
Enable /metrics OpenMetrics endpoint |
true |
cors.allowed_origins |
List of allowed CORS origins. Defaults to all origins (*) if empty. |
[] (all origins) |
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. |
"" |
cache.enabled |
Enable internal Redis cache for version resolution | false |
cache.type |
Cache backend (redis) |
redis |
cache.ttl |
Cache entry TTL in seconds | 3600 |
cache.file_path |
Path to the encrypted token file (used when tokenStore.mode is file) |
/tmp/gimme-tokens.enc |
redis_url |
Redis/Valkey connection URL. Required when tokenStore.mode is redis or cache.enabled is true. |
"" |
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 |
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 |
Reverse proxies. nginx limits request bodies to 1 MB by default. If gimme is behind nginx, set client_max_body_size high enough for upload.max_size; otherwise nginx returns 413 before the request reaches gimme.
Token store mode. By default (
tokenStore.mode: file), tokens are persisted to an encrypted local file — no external dependency needed. SettokenStore.mode: redisand provideredis_urlto share tokens across multiple instances. SettokenStore.mode: postgresand providetokenStore.pg_urlfor deployments that already have a PostgreSQL database.
Every key above has a GIMME_* equivalent. An environment variable always wins over the same key in the file, and a deployment that sets all the required ones needs no gimme.yml at all:
docker run -p 8080:8080 \
-e GIMME_SECRET=your-secret-at-least-32-chars-long \
-e GIMME_ADMIN_USER=gimmeadmin \
-e GIMME_ADMIN_PASSWORD=gimmeadmin \
-e GIMME_S3_URL=your.s3.endpoint \
-e GIMME_S3_KEY=your-access-key \
-e GIMME_S3_SECRET=your-secret-key \
-e GIMME_S3_LOCATION=garage \
ziggornif/gimmeA missing file is fine; a file that exists but cannot be parsed is still a startup error. Missing values are reported per field, whatever they were meant to come from.
| Key | Variable |
|---|---|
secret |
GIMME_SECRET |
admin.user |
GIMME_ADMIN_USER |
admin.password |
GIMME_ADMIN_PASSWORD |
port |
GIMME_APP_PORT |
s3.url |
GIMME_S3_URL |
s3.key |
GIMME_S3_KEY |
s3.secret |
GIMME_S3_SECRET |
s3.bucketName |
GIMME_S3_BUCKETNAME |
s3.location |
GIMME_S3_LOCATION |
s3.ssl |
GIMME_S3_SSL |
cors.allowed_origins |
GIMME_CORS_ALLOWED_ORIGINS |
metrics |
GIMME_METRICS |
redis_url |
GIMME_REDIS_URL |
token_file |
GIMME_TOKEN_FILE |
cache.enabled |
GIMME_CACHE_ENABLED |
cache.type |
GIMME_CACHE_TYPE |
cache.ttl |
GIMME_CACHE_TTL |
auth.mode |
GIMME_AUTH_MODE |
auth.oidc.issuer |
GIMME_AUTH_OIDC_ISSUER |
auth.oidc.client_id |
GIMME_AUTH_OIDC_CLIENT_ID |
auth.oidc.client_secret |
GIMME_AUTH_OIDC_CLIENT_SECRET |
auth.oidc.redirect_url |
GIMME_AUTH_OIDC_REDIRECT_URL |
auth.oidc.secure_cookies |
GIMME_AUTH_OIDC_SECURE_COOKIES |
tokenStore.mode |
GIMME_TOKENSTORE_MODE |
tokenStore.pg_url |
GIMME_TOKENSTORE_PG_URL |
upload.max_size |
GIMME_UPLOAD_MAX_SIZE |
upload.max_entries |
GIMME_UPLOAD_MAX_ENTRIES |
upload.max_uncompressed_size |
GIMME_UPLOAD_MAX_UNCOMPRESSED_SIZE |
The port variable is
GIMME_APP_PORT, notGIMME_PORT. For every Service in a namespace, Kubernetes injects a<SVCNAME>_PORTvariable into every pod, holding a URL such astcp://10.96.0.1:8080. The Helm chart names its Service after the release, sohelm install gimmeproduces exactlyGIMME_PORT. Gimme therefore binds each variable explicitly instead of mapping the wholeGIMME_prefix — otherwise the cluster would overwrite the configured port with a URL and the instance would fail to bind.
cors.allowed_origins is a list: separate the origins with commas. Spaces around a comma are ignored.
GIMME_CORS_ALLOWED_ORIGINS="https://app.example.com,https://admin.example.com"By default, Gimme uses HTTP Basic Auth to protect /admin and the token management API.
You can switch to an external OIDC provider (Keycloak, Dex, Auth0, …) with:
auth:
mode: oidc
oidc:
issuer: https://keycloak.example.com/realms/gimme
client_id: gimme
client_secret: "" # leave empty if your client is public
redirect_url: https://gimme.example.com/auth/callbackHow it works:
- Unauthenticated requests to
/admin,POST /tokens,DELETE /tokens/:idare redirected toGET /auth/login. GET /auth/loginstarts the OAuth2 authorization code flow (CSRF-protected with a state cookie).GET /auth/callbackvalidates the OIDC ID token, then issues a signed session cookie (HS256 JWT, 8 h TTL).
Keycloak quick setup:
- Create a realm named
gimme - Create a client named
gimme:- Client authentication: On
- Valid redirect URIs:
https://gimme.example.com/auth/callback
- Copy the client secret → set it as
auth.oidc.client_secret - Create users in the realm — they will be able to log in to
/admin
The Docker Compose
with-garageexample includes a commented-out Keycloak service. Seeexamples/deployment/docker-compose/with-garage/README.mdfor step-by-step instructions.
Breaking change: API tokens are now cryptographically random opaque strings (
gim_<hex>, 68 chars), stored as SHA-256 hashes in the token store (encrypted file or Redis). JWT tokens issued by previous versions are invalid and must be regenerated via/adminorPOST /tokens.The raw token is returned once — store it securely. Only its hash is persisted.
In basic mode, use your admin.user / admin.password as HTTP Basic Auth credentials:
curl -s -X POST http://localhost:8080/tokens \
-u gimmeadmin:gimmeadmin \
-H 'Content-Type: application/json' \
-d '{"name": "my-token", "expirationDate": "2027-12-31"}'In oidc mode, authenticate via the admin UI at /admin and use the token management interface.
Response: 201 Created
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "my-token",
"token": "gim_4a7b9c2d1e3f...",
"createdAt": "2026-02-28T10:00:00Z",
"expiresAt": "2027-12-31T00:00:00Z"
}The raw token (
gim_<hex>, 68 chars) is returned once — store it securely. Only its SHA-256 hash is persisted.If
expirationDateis omitted, the token expires in 90 days.
A package is a ZIP archive. The name and version fields identify it in the CDN.
curl -s -X POST http://localhost:8080/packages \
-H 'Authorization: Bearer <token>' \
-F 'file=@awesome-lib.zip' \
-F 'name=awesome-lib' \
-F 'version=1.0.0'Response: 201 Created
Uploads are limited by request size, ZIP file-entry count, and cumulative declared decompressed size. Exceeding any configured limit returns 413 Payload Too Large with an error naming the limit that was exceeded.
The paths inside the archive become the URLs, with one exception: when the archive wraps everything in a single top-level folder, that folder is stripped.
awesome-lib/app.js -> /gimme/awesome-lib@1.0.0/app.js
awesome-lib/img/logo.svg -> /gimme/awesome-lib@1.0.0/img/logo.svg
Any file sitting at the archive root means there is no wrapper, and every path is kept as it is:
app.js -> /gimme/awesome-lib@1.0.0/app.js
img/logo.svg -> /gimme/awesome-lib@1.0.0/img/logo.svg
README.md -> /gimme/awesome-lib@1.0.0/README.md
The same holds when the archive has several top-level folders — nothing is stripped.
macOS metadata is dropped and never published, and it does not count when looking for the wrapper folder — so an archive zipped from the Finder behaves like any other. Two patterns are removed: everything under a top-level __MACOSX/, and any .DS_Store at any depth. Everything else in the archive is uploaded as it is, unless a .gimmeignore says otherwise.
Put a .gimmeignore in the archive to keep files out of the package. It uses .gitignore syntax, and it is looked up in two places: the archive root, or — when the archive wraps everything in a single top-level folder — inside that folder. So both ways of building the archive work:
cd dist && zip -r ../pkg.zip . # .gimmeignore sits at the archive root
zip -r pkg.zip dist # .gimmeignore sits at dist/.gimmeignorePatterns 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.jsThe .gimmeignore itself is not published. A .gimmeignore anywhere else in the tree is treated as an ordinary file — uploaded, with no effect.
An excluded file does not count when looking for the wrapper folder, which is how you publish a dist/ folder alongside files you do not want served:
dist/app.js, dist/css/style.css, README.md with a .gimmeignore holding "README.md"
-> /gimme/awesome-lib@1.0.0/app.js
/gimme/awesome-lib@1.0.0/css/style.css
A .gitignore is not honoured, deliberately: it usually lists dist/, so applying it would drop the very files you meant to publish. It is uploaded like any other file.
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.
An archive is rejected whole, and nothing is uploaded, when an entry escapes the package namespace (../, absolute paths, empty names) or when two entries land on the same URL.
Entry names are normalised to Unicode NFC. Accented filenames are stored by macOS in a decomposed form (e followed by a combining accent) but requested by browsers in the composed one, so without normalisation 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, rather than silently becoming two objects.
Once uploaded, files are served at:
GET /gimme/<package>@<version>/<file>curl http://localhost:8080/gimme/awesome-lib@1.0.0/awesome-lib.min.jsSemver partial versions are supported — awesome-lib@1.0 resolves to the latest 1.0.x available.
CORS: CORS is configurable via
cors.allowed_originsingimme.yml. If left empty (the default), all origins are allowed (*) — suitable for a public CDN. Set it to a list of trusted origins to restrict cross-origin access.
Use it directly in HTML:
<link rel="stylesheet" href="http://localhost:8080/gimme/awesome-lib@1.0.0/awesome-lib.min.css">
<script src="http://localhost:8080/gimme/awesome-lib@1.0.0/awesome-lib.min.js" type="module"></script>GET /gimme/<package>@<version>Returns an HTML page listing all files in the package.
curl http://localhost:8080/gimme/awesome-lib@1.0.0curl -s -X DELETE http://localhost:8080/packages/awesome-lib@1.0.0 \
-H 'Authorization: Bearer <token>'Response: 204 No Content
| Method | Route | Auth | Description |
|---|---|---|---|
GET |
/ |
— | HTML homepage |
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 files in a package (HTML) |
GET |
/gimme/:package/*file |
— | Serve a file from a package |
GET |
/metrics |
— | Prometheus / OpenMetrics endpoint |
GET |
/docs |
— | Interactive API documentation |
GET |
/healthz |
— | Liveness probe |
GET |
/readyz |
— | Readiness probe (checks S3 bucket) |
The examples/deployment directory contains ready-to-use configurations:
| Stack | Path | Description |
|---|---|---|
| Docker Compose + managed S3 | with-managed-s3/ |
gimme + any cloud S3 provider (AWS, OVH, Scaleway, Cellar, …) + monitoring |
| Docker Compose + Garage | with-garage/ |
Self-provisioning stack with self-hosted Garage + monitoring |
| Kubernetes | kubernetes/ |
Namespace, Deployment, Service, Ingress |
| systemd | systemd/ |
Linux systemd unit file |
docker run -p 8080:8080 \
-v "$(pwd)/gimme.yml:/config/gimme.yml" \
ziggornif/gimme:latestThe Docker image reads its config from
/config/gimme.yml. Mount your localgimme.ymlto that path as shown above.
Gimme implements two independent, composable caching levels:
Browser → [Level 1: external proxy / CDN] → [gimme + Level 2: internal Redis cache] → [S3]
Gimme automatically emits Cache-Control headers on every file response, allowing any HTTP cache (browser, CDN, reverse proxy) to cache assets without any extra configuration.
| Version type | Example | Cache-Control header |
|---|---|---|
| Pinned (3-part semver) | pkg@1.0.0 |
public, max-age=31536000, immutable |
| Partial | pkg@1.0 or pkg@1 |
public, max-age=300 |
| Not found (404) | any | no-store |
Pinned versions (pkg@1.0.0) are immutable by design — the same URL always resolves to exactly the same files. Browsers and proxies can cache them for up to 1 year with no revalidation.
Partial versions (pkg@1.0) resolve to the latest matching patch at request time, so they are only cached for 5 minutes.
404 responses are never cached, to avoid propagating transient misses.
Every file response also carries ETag and Last-Modified. A cache that revalidates with If-None-Match — or with If-Modified-Since, which is consulted only when If-None-Match is absent — gets a 304 Not Modified with no body when the file has not changed. That is what makes the 5-minute revalidation of a partial version cost a set of headers instead of the whole file.
Any HTTP cache that honours Cache-Control headers will work in front of gimme — Nginx, Varnish, Caddy (with the cache-handler plugin), Cloudflare, Fastly, etc.
Configure your proxy to cache /gimme/* responses and pass the Cache-Control header through. The headers emitted by gimme are enough to drive the caching policy:
- Pinned versions (
pkg@1.0.0) —immutable, safe to cache for 1 year. - Partial versions (
pkg@1.0) —max-age=300, revalidated every 5 minutes; an unchanged file answers304with no body. - 404 / errors —
no-store, never cached.
Gimme includes an optional internal cache backed by Redis / Valkey. When enabled, it caches the result of partial version resolution (pkg@1.0 → pkg@1.0.3) so that S3 ListObjects calls are avoided on repeated requests.
The file body is always streamed directly from S3 — only the resolved S3 object path is cached.
- A request arrives for
GET /gimme/pkg@1.0/file.js(partial version). - Gimme looks up the key
pkg@1.0/file.jsin Redis. - Cache hit → the resolved path (e.g.
pkg@1.0.3/file.js) is returned immediately; S3ListObjectsis skipped. - Cache miss → gimme resolves the latest version via S3, stores the result in Redis with the configured TTL, then streams the file.
- When a package is deleted (
DELETE /packages/pkg@1.0.3), cache entries whose key starts withpkg@1.0.3are invalidated. Partial-version entries (e.g.pkg@1.0/file.js) are not touched — they will naturally expire via the TTL and resolve to the next available version on the following request.
Pinned versions (pkg@1.0.0) are not stored in Redis — their path is deterministic and requires no resolution.
redis_url: redis://localhost:6379
cache:
enabled: true
type: redis # only "redis" is supported; "memory" is reserved for future use
ttl: 3600 # TTL in seconds (default: 3600)| Key | Description | Default |
|---|---|---|
cache.enabled |
Enable the internal cache | false |
cache.type |
Cache backend (redis) |
redis |
cache.ttl |
Entry TTL in seconds | 3600 |
redis_url |
Redis/Valkey connection URL | redis://localhost:6379 |
A ready-to-use stack with Garage + Valkey is available in examples/deployment/docker-compose/with-garage/. Add the following to your gimme.yml to enable the cache:
redis_url: redis://valkey:6379
cache:
enabled: true
type: redis
ttl: 3600Each gimme instance exposes a /metrics endpoint in OpenMetrics format, compatible with Prometheus.
In addition to the standard Go runtime and process metrics (goroutines, memory, GC, CPU), gimme exposes the following application-level metrics:
| Metric | Type | Labels | Description |
|---|---|---|---|
gimme_http_requests_total |
Counter | route, method, status_code |
Total HTTP requests handled, partitioned by Gin route pattern (e.g. /gimme/:package/*file), HTTP method and response status code |
| Metric | Type | Labels | Description |
|---|---|---|---|
gimme_s3_operation_duration_seconds |
Histogram | operation |
Duration of S3 operations in seconds. operation values: AddObject, GetObject, ListObjects, ObjectExists, RemoveObjects, Ping |
| Metric | Type | Labels | Description |
|---|---|---|---|
gimme_cache_hits_total |
Counter | — | Cache hits on partial-version resolution (e.g. pkg@1.0 → resolved path served from cache) |
gimme_cache_misses_total |
Counter | — | Cache misses on partial-version resolution |
| Metric | Type | Labels | Description |
|---|---|---|---|
gimme_packages_uploaded_total |
Counter | — | Total packages successfully uploaded via POST /packages |
gimme_packages_deleted_total |
Counter | — | Total packages successfully deleted via DELETE /packages/:package |
A pre-configured Prometheus + Grafana stack is bundled in both Docker Compose examples (with-garage/ and with-managed-s3/). Each stack includes its own monitoring/ directory with the Prometheus config and Grafana dashboard.
Once a stack is running:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000 (anonymous access enabled)
