diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d5d8838273318b77c1986732b5c095d64174b232
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,10 @@
+build giphy proxy docker:
+  image: docker:stable
+  stage: build
+  before_script:
+  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+  script:
+  - docker build -t $CI_REGISTRY_IMAGE/giphyproxy:latest giphyproxy
+  - docker push $CI_REGISTRY_IMAGE/giphyproxy:latest
+  only:
+  - master
diff --git a/giphyproxy/Dockerfile b/giphyproxy/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..6088f99f76faa8c7eeb4e06784a41c5311d35135
--- /dev/null
+++ b/giphyproxy/Dockerfile
@@ -0,0 +1,16 @@
+FROM golang:1-alpine AS builder
+
+RUN apk add --no-cache ca-certificates
+WORKDIR /build/giphyproxy
+COPY . /build/giphyproxy
+ENV CGO_ENABLED=0
+RUN go build -o /usr/bin/giphyproxy
+
+FROM scratch
+
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+COPY --from=builder /usr/bin/giphyproxy /usr/bin/giphyproxy
+
+VOLUME /data
+WORKDIR /data
+CMD ["/usr/bin/giphyproxy"]
diff --git a/giphyproxy/example-config.yaml b/giphyproxy/example-config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3bed981d92866330568fba0292e92882edea0e27
--- /dev/null
+++ b/giphyproxy/example-config.yaml
@@ -0,0 +1,17 @@
+# The server name to use for the custom mxc:// URIs.
+# This server name will effectively be a real Matrix server, it just won't implement anything other than media.
+# You must either set up .well-known delegation from this domain to this program, or proxy the domain directly to this program.
+server_name: giphy.example.com
+# Optionally a custom .well-known response. This defaults to `server_name:443` if empty.
+well_known_response:
+# The proxy will use MSC3860/MSC3916 media download redirects if the requester supports it.
+# Optionally, you can force redirects and not allow proxying at all by setting this to false.
+allow_proxy: false
+# Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file.
+# You can generate one using `giphyproxy -generate-key`.
+server_key: CHANGE ME
+
+# Hostname where the proxy should listen on
+hostname: 0.0.0.0
+# Port where the proxy should listen on
+port: 8008
diff --git a/giphyproxy/go.mod b/giphyproxy/go.mod
new file mode 100644
index 0000000000000000000000000000000000000000..8c26d75870627b8a385807de39b502e610163e8c
--- /dev/null
+++ b/giphyproxy/go.mod
@@ -0,0 +1,24 @@
+module go.mau.fi/stickerpicker/giphyproxy
+
+go 1.22.3
+
+require (
+	go.mau.fi/util v0.5.0
+	gopkg.in/yaml.v3 v3.0.1
+	maunium.net/go/mautrix v0.19.0-beta.1.0.20240619084603-3e302fb46fdb
+)
+
+require (
+	github.com/gorilla/mux v1.8.0 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.19 // indirect
+	github.com/rs/zerolog v1.33.0 // indirect
+	github.com/tidwall/gjson v1.17.1 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.0 // indirect
+	github.com/tidwall/sjson v1.2.5 // indirect
+	golang.org/x/crypto v0.24.0 // indirect
+	golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
+	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/sys v0.21.0 // indirect
+)
diff --git a/giphyproxy/go.sum b/giphyproxy/go.sum
new file mode 100644
index 0000000000000000000000000000000000000000..47a7ac322b6c00ba3ec172dfee06f36679a9b022
--- /dev/null
+++ b/giphyproxy/go.sum
@@ -0,0 +1,47 @@
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
+github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
+github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+go.mau.fi/util v0.5.0 h1:8yELAl+1CDRrwGe9NUmREgVclSs26Z68pTWePHVxuDo=
+go.mau.fi/util v0.5.0/go.mod h1:DsJzUrJAG53lCZnnYvq9/mOyLuPScWwYhvETiTrpdP4=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+maunium.net/go/mautrix v0.19.0-beta.1.0.20240619084603-3e302fb46fdb h1:xOe8J6rG2ADTVl56+SFDBDJkwmfCzNpVHLDxHZS+c0w=
+maunium.net/go/mautrix v0.19.0-beta.1.0.20240619084603-3e302fb46fdb/go.mod h1:cxv1w6+syudmEpOewHYIQT9yO7TM5UOWmf6xEBVI4H4=
diff --git a/giphyproxy/main.go b/giphyproxy/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..279afcb3e3a1f7c06a72c61464d39fa592804213
--- /dev/null
+++ b/giphyproxy/main.go
@@ -0,0 +1,62 @@
+// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"regexp"
+
+	"go.mau.fi/util/exerrors"
+	"gopkg.in/yaml.v3"
+	"maunium.net/go/mautrix/federation"
+	"maunium.net/go/mautrix/mediaproxy"
+)
+
+type Config struct {
+	mediaproxy.BasicConfig  `yaml:",inline"`
+	mediaproxy.ServerConfig `yaml:",inline"`
+}
+
+var configPath = flag.String("config", "config.yaml", "config file path")
+var generateServerKey = flag.Bool("generate-key", false, "generate a new server key and exit")
+
+var giphyIDRegex = regexp.MustCompile(`^[a-zA-Z0-9-_]+$`)
+
+func main() {
+	flag.Parse()
+	if *generateServerKey {
+		fmt.Println(federation.GenerateSigningKey().SynapseString())
+	} else {
+		cfgFile := exerrors.Must(os.ReadFile(*configPath))
+		var cfg Config
+		exerrors.PanicIfNotNil(yaml.Unmarshal(cfgFile, &cfg))
+		mp := exerrors.Must(mediaproxy.NewFromConfig(cfg.BasicConfig, getMedia))
+		exerrors.PanicIfNotNil(mp.Listen(cfg.ServerConfig))
+	}
+}
+
+func getMedia(_ context.Context, id string) (response mediaproxy.GetMediaResponse, err error) {
+	if !giphyIDRegex.MatchString(id) {
+		return nil, mediaproxy.ErrInvalidMediaIDSyntax
+	}
+	return &mediaproxy.GetMediaResponseURL{
+		URL: fmt.Sprintf("https://i.giphy.com/%s.webp", id),
+	}, nil
+}