A lightweight, standalone file upload server with a modern web interface. Built with Go for the backend and Dart/Flutter for the frontend, this container provides a simple yet powerful solution for storing and managing files via REST API.
docker run -d \
--name artifact-server \
-p 8080:8080 \
-v ./uploads:/var/uploads \
-e ART_API_TOKEN=your-secret-token \
davbfr/artifact:latest
Access the web interface at http://localhost:8080
Press alt to reveal the login button.
file:create, file:delete, tag:add and tag:remove permissions, and the session token carries the result. See Authorization/s/{slug} link for sharing/downloading/f/{filename} always resolves to the latest version of that name; disable via ART_NO_FILENAME_URLname:suffix tags (cat:latest, pets/cat:6.0) at upload or later; every tag gets a stable /t/{tag} link that always resolves to the file it currently points at. The UI shows a file's tags inline (click one to copy its link) and keeps full tag management, along with every other detail, in the file properties dialog| Variable | Description | Default |
|---|---|---|
ART_API_TOKEN | Authentication token for API access (required for uploads/deletes) | None |
ART_SESSION_SECRET | Signing key for session tokens. Enables token auth on its own (min. 32 chars) | None |
ART_SESSION_TTL | Lifetime of a session token minted by an OIDC login (e.g. 12h, 30d) | 12h |
ART_OIDC_ISSUER | OIDC issuer URL; set with the client id to enable provider login | None |
ART_OIDC_CLIENT_ID | OIDC client id registered with the provider | None |
ART_OIDC_CLIENT_SECRET | OIDC client secret; omit for a PKCE-only client | None |
ART_OIDC_SCOPES | Space-separated OIDC scopes; groups is appended automatically when ART_OIDC_GROUPS is set | openid profile |
ART_OIDC_REDIRECT_URL | Redirect URI sent to the provider; must match its registration | <scheme>://<host>/api/auth/callback |
ART_OIDC_GROUPS_CLAIM | id_token claim holding group membership | groups |
ART_ROLES | JSON object mapping each role to the permissions it grants; set, it replaces the built-in roles | see Authorization |
ART_OIDC_GROUPS | JSON object mapping an OIDC group to roles; unset grants every authenticated user admin | {"*":["admin"]} |
ART_PORT | Port to listen on | 8080 |
ART_UPLOAD_FOLDER | Data directory: holds the sqlite file-record database, plus the uploaded files themselves when ART_STORAGE=fs | /var/uploads |
ART_STORAGE | Where uploaded files live: fs (local disk) or s3 | fs |
ART_S3_BUCKET | Bucket for uploaded files; required when ART_STORAGE=s3 | None |
ART_S3_PREFIX | Key prefix inside the bucket (e.g. artifacts) | none (bucket root) |
ART_S3_REGION | Region for the endpoint; defaults to the SDK's own resolution | None |
ART_S3_ENDPOINT | Custom endpoint for S3-compatible stores (MinIO, Ceph, R2) | None (AWS) |
ART_S3_PATH_STYLE | Bucket-in-path addressing, needed by most S3-compatible stores | true when ART_S3_ENDPOINT is set |
ART_STATIC_FOLDER | Directory for static web files | /app/static |
ART_MAX_FILE_SIZE | Maximum file size (e.g., "100M", "1G") | 100M |
ART_WEB_PORTAL | Serve the web interface. false disables it entirely (404s) | true |
ART_NO_LISTING | true requires a valid API token to call GET /api/files and GET /api/stats | false |
ART_APPEND_ONLY | true keeps every upload as a separate file (no replacing) and disables deletion | false |
ART_MAX_LIST_LIMIT | Hard cap on the number of files returned per GET /api/files request | 500 |
ART_NO_FILENAME_URL | true disables GET /f/{filename} entirely (404s) | false |
ART_READ_ONLY | true serves only: every mutation answers 403. See High Availability | false |
ART_DB_REPLICA | true downloads the record database from the bucket and serves it; implies ART_READ_ONLY | false |
ART_DB_BACKUP_DELAY | Publish the record database to the bucket after this much quiet. 0 disables publishing | 0 |
ART_DB_BACKUP_PREFIX | Key prefix for the published database inside the bucket | _backup |
ART_DB_POLL_INTERVAL | How often a replica asks whether a new database has been published | 60s |
ART_DB_RESTORE | What an absent local database means: auto restores it, ignore starts fresh, unset refuses to start | unset |
/var/uploads - Holds the artifact.db sqlite file-record database, and with ART_STORAGE=fs
(the default) the uploaded files too. With S3 storage nothing else is needed locally, so the
directory only needs enough space for the database.ART_DB_REPLICA=true) needs no volume at all: the directory is scratch
space for the snapshot it downloads, so an emptyDir or /tmp is enough.Uploaded files are kept either on local disk or in an S3 bucket.
ART_STORAGE=fs stores each upload under ART_UPLOAD_FOLDER in a slug-sharded layout
(ab/cdefgh...), decoupled from its display name. Nothing changes for existing deployments.
ART_STORAGE=s3)ART_STORAGE=s3
ART_S3_BUCKET=my-artifacts # object keys are <prefix>/<slug-sharded key>
ART_S3_PREFIX=artifacts # optional
ART_S3_REGION=eu-west-1 # optional; the SDK resolves it otherwise
ART_S3_ENDPOINT=https://s3.example.com # optional, for non-AWS stores
AWS_ACCESS_KEY_ID=... # or any other credential source below
AWS_SECRET_ACCESS_KEY=...
Credentials come from the standard AWS chain - environment, shared config, or a container/instance role - so nothing secret has to live in the app's own configuration.
MinIO and other S3-compatible stores work through ART_S3_ENDPOINT, which also switches on
path-style addressing (bucket in the path) by default. Override with ART_S3_PATH_STYLE=false
only if your endpoint needs virtual-host addressing.
Nothing is staged on local disk: uploads stream straight to the bucket through the SDK's transfer manager, in buffered parts, and the size cap is still enforced while reading. Downloads read ranges straight from the object, so byte ranges and resumable transfers keep working.
The record database is local to each instance. SQLite needs a local file, so
ART_UPLOAD_FOLDER is still required with S3 - it just holds artifact.db instead of the
uploads. There is exactly one writer: S3 makes the files shareable between instances,
and the metadata is shared by replicating the database as a read-only snapshot, which is
what High Availability describes.
Migrating an existing deployment is a straight copy, because an object key is the
storage_key the database already stores:
aws s3 sync /var/uploads s3://my-artifacts/artifacts/ --exclude 'artifact.db*'
Uploaded files whose records are marked deleted can be left behind or swept separately; only rows the database still lists as live are served.
The server runs as one writer with any number of read-only replicas. A replica is stateless: it has no volume of its own, it downloads the record database from the bucket, and it refuses every mutation. Uploads, deletes and tag changes go to the writer; downloads are served by whichever instance is closest.
The writer publishes the database:
ART_DB_BACKUP_DELAY, and once more on shutdown, it runs
VACUUM INTO to produce a consistent single-file copy - no write-ahead log, no sidecars - and
uploads it to <ART_DB_BACKUP_PREFIX>/artifact.db.PRAGMA user_version, so a reader can tell whether it
understands a snapshot before it tries to use it.ART_DB_RESTORE=ignore.A replica subscribes to it:
ART_DB_POLL_INTERVAL (jittered, so replicas do not stampede) it asks whether the object
changed, and downloads it only when it did.GetObject and nothing else. It
cannot damage the data even if it is compromised.A complete local stack: a demo MinIO for the bucket, one writer, three replicas, and Traefik in
front. Point ART_S3_* at your own bucket and drop minio, minio-init and their depends_on
to run it for real.
services:
# ---------------------------------------------------------------------------
# Demo object store. These are MinIO's default credentials - fine for a local
# stack, and never for anything reachable.
# ---------------------------------------------------------------------------
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports: ["9000:9000", "9001:9001"] # API, console
volumes: ["minio:/data"]
# Creates the bucket once MinIO is up: the server never creates one itself.
minio-init:
image: minio/mc:latest
depends_on: [minio]
entrypoint: >
/bin/sh -c "
until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
mc mb --ignore-existing local/my-artifacts;
"
# The writer owns the data: it is the only instance with a volume, and the
# only one that accepts a mutation.
artifact:
image: davbfr/artifact:latest
depends_on: [minio-init]
volumes: ["./uploads:/var/uploads"]
environment: &artifact-env
ART_API_TOKEN: ${ART_API_TOKEN}
ART_STORAGE: s3
ART_S3_BUCKET: my-artifacts
ART_S3_ENDPOINT: http://minio:9000 # implies path-style addressing
ART_S3_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
ART_DB_BACKUP_DELAY: 15s # how far behind the replicas may be
labels:
traefik.enable: "true"
# Everything that is not a download goes to the writer: the web UI, and
# all of /api/. The API cannot be split by prefix, because /api/tags/{slug}
# is a read as a GET and a mutation as a POST.
traefik.http.routers.artifact.rule: "PathPrefix(`/`)"
traefik.http.routers.artifact.priority: "1"
traefik.http.routers.artifact.service: artifact-writer
traefik.http.services.artifact-writer.loadbalancer.server.port: "8080"
# Downloads are the one thing every instance can do identically, so they
# are the only routes spread across the replicas: /s/, /f/ and /t/ are
# downloads and nothing else, so no mutation can land on a read-only
# instance by going through them.
traefik.http.routers.artifact-downloads.rule: "PathPrefix(`/s/`) || PathPrefix(`/f/`) || PathPrefix(`/t/`)"
traefik.http.routers.artifact-downloads.priority: "100"
traefik.http.routers.artifact-downloads.service: artifact-downloads
traefik.http.services.artifact-downloads.loadbalancer.server.port: "8080"
traefik.http.services.artifact-downloads.loadbalancer.healthcheck.path: "/api/health"
# Replicas serve downloads. They need no volume at all and no host port: they
# are only ever reached through the load balancer.
artifact-replica:
image: davbfr/artifact:latest
depends_on: [minio-init]
environment:
<<: *artifact-env
ART_READ_ONLY: "true"
ART_DB_REPLICA: "true"
ART_DB_POLL_INTERVAL: 60s
ART_WEB_PORTAL: "false"
ART_UPLOAD_FOLDER: /tmp/artifact-db # scratch for the downloaded database
labels:
traefik.enable: "true"
# Declaring the same service name is what puts a replica into the download
# load-balancer. It gets no routers of its own.
traefik.http.services.artifact-downloads.loadbalancer.server.port: "8080"
deploy:
replicas: 3
traefik:
image: traefik:v3
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
ports: ["8080:80"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]
volumes:
minio:
The whole thing then lives at http://localhost:8080, and MinIO's console at
http://localhost:9001.
Note that one bucket holds both parts: the uploaded files under ART_S3_PREFIX, and the record
database snapshot under _backup/. Those keyspaces cannot overlap - storage keys are sharded by
the first two characters of a slug, and slugs are [A-Za-z0-9] only, so a directory starting
with _ can never be a shard.
Set
ART_WEB_PORTAL=falseon the replicas. The routing above already sends the UI to the writer, so a visitor never lands on a replica - but turning the portal off means a replica cannot serve an interface that would not work, even if it is reached directly. A replica's upload, delete and tag controls would fail with403, because nothing there can change data. With the portal off, each replica is a pure API and download origin: no ~17MB JavaScript bundle served per visitor, and no interface that looks functional but is not.
Replicas answer mutations with 403, so a load balancer that round-robins everything will fail
uploads at random. Send the four mutating routes to the writer and spread the reads across every
instance:
| Route | Send to |
|---|---|
POST /api/upload | writer only |
DELETE /api/delete/{slug} | writer only |
POST /api/tags/{slug} | writer only |
DELETE /api/tags/{slug}/{tag} | writer only |
| /s/ /f/ /t/ | any instance |
| everything else | writer only |
In the compose above this is two Traefik routers: a catch-all PathPrefix(/) pointing at the
writer, and a higher-priority PathPrefix(/s/) || PathPrefix(/f/) || PathPrefix(/t/)
pointing at a service every instance joins. Traefik would prefer the longer rule on its own, but
the explicit priority makes that independent of how the rules are written.
The split is by path because that is the only thing a router can see. /s/, /f/ and /t/ are
downloads and nothing else, whereas /api/tags/{slug} is a read as a GET and a mutation as a
POST, so every /api/ route stays on the writer.
ART_DB_BACKUP_DELAY + ART_DB_POLL_INTERVAL. A newly uploaded file, or
a deletion, may take that long to appear on a replica.ART_STORAGE=s3. A replica has no local blobs, so with fs storage it would
list files it can never serve. This is checked at startup rather than left to fail per request.restore-db writes the published snapshot over the local database:
# In a running container, replacing the database it has
docker exec artifact-server /app/upload_server restore-db -force
# Before starting, into a fresh data directory (the bucket config has to be passed)
docker run --rm -v ./uploads:/var/uploads \
-e ART_S3_BUCKET=my-artifacts davbfr/artifact:latest restore-db
If the database is missing while a snapshot exists, the server refuses to start rather than quietly creating an empty one - a fresh deployment and a lost volume look identical, and only the snapshot store can tell them apart:
no database at /var/uploads/artifact.db, but a snapshot is published at _backup/artifact.db (8.4 MB, 2026-09-27T14:15:30Z)
Restore it: upload_server restore-db
Start fresh: ART_DB_RESTORE=ignore
ART_DB_RESTORE=auto restores automatically instead, for a deployment that would rather come back
by itself. Setting the backup delay to a non-zero value also gives a filesystem-backed instance an
offsite copy of its metadata in the bucket.
Uploading, deleting and tagging always require a credential, and the credential needs the
permission for the change it is making (see Authorization); listing and
downloading are public unless ART_NO_LISTING=true.
Two kinds of credential are accepted, as Authorization: Bearer <token> or a bare
Authorization: <token>:
ART_API_TOKEN - a shared secret, as before.ART_SESSION_SECRET. This is what the
browser receives after an OIDC login, and what the token command below mints.ART_SESSION_SECRET is a complete authentication configuration on its own: with it set,
uploads and deletes demand a valid session token instead of reporting that no token is
configured. It must be at least 32 characters, and tokens carry iss=artifact-server and
aud=artifact-api, so a secret shared with another service cannot be used to forge one.
# Valid for 30 days
# Granted no permissions, so it can read but not change anything
docker exec artifact-server /app/upload_server token -sub ci -ttl 30d
# Valid for ART_SESSION_TTL, 12h by default
docker exec artifact-server /app/upload_server token -sub ci
# Never expires - a bearer credential that outlives everything but a secret rotation
docker exec artifact-server /app/upload_server token -sub build-agent -ttl 0
# Grant permissions explicitly; without -perms a minted token has none
docker exec artifact-server /app/upload_server token -sub ci -perms file:create,tag:add
Only the token goes to stdout, so it can be captured directly:
TOKEN=$(docker exec artifact-server /app/upload_server token -sub ci -ttl 720h -perms file:create)
curl -H "Authorization: Bearer $TOKEN" -F "[email protected]" http://localhost:8080/api/upload
A minted token carries no permissions unless -perms says otherwise, so it is read-only by
default. ART_API_TOKEN is the opposite: it predates permissions and always carries every one of
them.
Rotating ART_SESSION_SECRET invalidates every session and every minted token at once.
There is no revocation list and no way to log out a single user, so rotation is the lever for
an emergency logoff.
Setting both ART_OIDC_ISSUER and ART_OIDC_CLIENT_ID (alongside the required
ART_SESSION_SECRET) switches the web UI to provider login. Register this exact redirect URI
with the provider first - login fails with a redirect-URI error otherwise:
http://localhost:8080/api/auth/callback
When OIDC is configured:
alt) sends the browser to /api/auth/login, which
redirects to the provider with PKCE, a state and a nonce. The provider returns to
/api/auth/callback. The token form is not offered in the web UI in this mode, although the
API still accepts ART_API_TOKEN.id_token and mints a session token for the browser
(ART_SESSION_TTL, 12h by default). The provider's own access token is discarded: only
tokens this server signed are accepted.ART_OIDC_GROUPS to decide what the
login may do. By default that is admin for everyone, which is what the server did before
roles existed - see Authorization to restrict it./api/auth/login answeContent type
Image
Digest
sha256:5faa9d750…
Size
6.3 MB
Last updated
5 days ago
docker pull davbfr/artifact