瀏覽代碼

env package introduced (#1538)

Victor Sokolov 1 周之前
父節點
當前提交
f55d1da1f4

+ 16 - 0
CHANGELOG.v4.md

@@ -1,5 +1,21 @@
 # 📑 Changelog (version/4 dev)
 
+## 2025-09-26
+
+### ❌ Removed
+
+- Deprecated `IMGPROXY_WRITE_TIMEOUT` is removed.
+- Deprecated `IMGPROXY_READ_TIMEOUT` is removed.
+- Obsolete `IMGPROXY_MAX_SVG_CHECK_BYTES` is removed.
+- Obsolete `IMGPROXY_ETAG_BUSTER` is removed.
+- `IMGPROXY_USE_*` behaviour changed: now, it does not rely on the key
+
+## 2025-09-25
+
+### 🔄 Changed
+
+- `IMGPROXY_USE_GCS` is not automatically set if gcs key is present anymore.
+
 ## ✨ 2025-08-27
 
 ### 🔄 Changed

+ 17 - 7
auximageprovider/static_config.go

@@ -1,8 +1,18 @@
 package auximageprovider
 
 import (
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_WATERMARK_DATA = env.Describe("IMGPROXY_WATERMARK_DATA", "base64-encoded string")
+	IMGPROXY_WATERMARK_PATH = env.Describe("IMGPROXY_WATERMARK_PATH", "path")
+	IMGPROXY_WATERMARK_URL  = env.Describe("IMGPROXY_WATERMARK_URL", "URL")
+
+	IMGPROXY_FALLBACK_IMAGE_DATA = env.Describe("IMGPROXY_FALLBACK_IMAGE_DATA", "base64-encoded string")
+	IMGPROXY_FALLBACK_IMAGE_PATH = env.Describe("IMGPROXY_FALLBACK_IMAGE_PATH", "path")
+	IMGPROXY_FALLBACK_IMAGE_URL  = env.Describe("IMGPROXY_FALLBACK_IMAGE_URL", "URL")
 )
 
 // StaticConfig holds the configuration for the auxiliary image provider
@@ -25,9 +35,9 @@ func NewDefaultStaticConfig() StaticConfig {
 func LoadWatermarkStaticConfigFromEnv(c *StaticConfig) (*StaticConfig, error) {
 	c = ensure.Ensure(c, NewDefaultStaticConfig)
 
-	c.Base64Data = config.WatermarkData
-	c.Path = config.WatermarkPath
-	c.URL = config.WatermarkURL
+	env.String(&c.Base64Data, IMGPROXY_WATERMARK_DATA)
+	env.String(&c.Path, IMGPROXY_WATERMARK_PATH)
+	env.String(&c.URL, IMGPROXY_WATERMARK_URL)
 
 	return c, nil
 }
@@ -36,9 +46,9 @@ func LoadWatermarkStaticConfigFromEnv(c *StaticConfig) (*StaticConfig, error) {
 func LoadFallbackStaticConfigFromEnv(c *StaticConfig) (*StaticConfig, error) {
 	c = ensure.Ensure(c, NewDefaultStaticConfig)
 
-	c.Base64Data = config.FallbackImageData
-	c.Path = config.FallbackImagePath
-	c.URL = config.FallbackImageURL
+	env.String(&c.Base64Data, IMGPROXY_FALLBACK_IMAGE_DATA)
+	env.String(&c.Path, IMGPROXY_FALLBACK_IMAGE_PATH)
+	env.String(&c.URL, IMGPROXY_FALLBACK_IMAGE_URL)
 
 	return c, nil
 }

+ 6 - 8
cli/healthcheck.go

@@ -8,20 +8,18 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/config/configurators"
+	"github.com/imgproxy/imgproxy/v3/env"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/urfave/cli/v3"
 )
 
 // healthcheck performs a healthcheck on a running imgproxy instance
 func healthcheck(ctx context.Context, c *cli.Command) error {
-	network := config.Network
-	bind := config.Bind
-	pathprefix := config.PathPrefix
+	var network, bind, pathprefix string
 
-	configurators.String(&network, "IMGPROXY_NETWORK")
-	configurators.String(&bind, "IMGPROXY_BIND")
-	configurators.URLPath(&pathprefix, "IMGPROXY_PATH_PREFIX")
+	env.String(&network, server.IMGPROXY_NETWORK)
+	env.String(&bind, server.IMGPROXY_BIND)
+	env.String(&pathprefix, server.IMGPROXY_PATH_PREFIX)
 
 	httpc := http.Client{
 		Transport: &http.Transport{

+ 2 - 5
cli/main.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/imgproxy/imgproxy/v3"
 	"github.com/imgproxy/imgproxy/v3/logger"
+	optionsparser "github.com/imgproxy/imgproxy/v3/options/parser"
 	"github.com/imgproxy/imgproxy/v3/version"
 	"github.com/urfave/cli/v3"
 )
@@ -21,10 +22,6 @@ func ver(ctx context.Context, c *cli.Command) error {
 
 // run starts the imgproxy server
 func run(ctx context.Context, cmd *cli.Command) error {
-	// NOTE: for now, this flag is loaded in config.go package
-
-	// presets := cmd.String("presets")
-
 	if err := imgproxy.Init(); err != nil {
 		return err
 	}
@@ -55,7 +52,7 @@ func main() {
 		Usage: "Fast and secure standalone server for resizing and converting remote images",
 		Flags: []cli.Flag{
 			&cli.StringFlag{
-				Name:  "presets",
+				Name:  optionsparser.PresetsFlagName,
 				Usage: "path of the file with presets",
 			},
 		},

+ 149 - 0
env/aws.go

@@ -0,0 +1,149 @@
+package env
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsConfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
+	"github.com/aws/aws-sdk-go-v2/service/ssm"
+)
+
+const (
+	// defaultAWSRegion represents default AWS region for all configuration
+	defaultAWSRegion = "us-west-1"
+)
+
+var (
+	IMGPROXY_ENV_AWS_SECRET_ID             = Describe("IMGPROXY_ENV_AWS_SECRET_ID", "string")
+	IMGPROXY_ENV_AWS_SECRET_VERSION_ID     = Describe("IMGPROXY_ENV_AWS_SECRET_VERSION_ID", "string")
+	IMGPROXY_ENV_AWS_SECRET_VERSION_STAGE  = Describe("IMGPROXY_ENV_AWS_SECRET_VERSION_STAGE", "string")
+	IMGPROXY_ENV_AWS_SECRET_REGION         = Describe("IMGPROXY_ENV_AWS_SECRET_REGION", "AWS region ("+defaultAWSRegion+")")
+	IMGPROXY_ENV_AWS_SSM_PARAMETERS_PATH   = Describe("IMGPROXY_ENV_AWS_SSM_PARAMETERS_PATH", "string")
+	IMGPROXY_ENV_AWS_SSM_PARAMETERS_REGION = Describe("IMGPROXY_ENV_AWS_SSM_PARAMETERS_REGION", "AWS region ("+defaultAWSRegion+")")
+)
+
+func loadAWSSecret(ctx context.Context) error {
+	var secretID, secretVersionID, secretVersionStage, secretRegion string
+
+	String(&secretID, IMGPROXY_ENV_AWS_SECRET_ID)
+	String(&secretVersionID, IMGPROXY_ENV_AWS_SECRET_VERSION_ID)
+	String(&secretVersionStage, IMGPROXY_ENV_AWS_SECRET_VERSION_STAGE)
+	String(&secretRegion, IMGPROXY_ENV_AWS_SECRET_REGION)
+
+	// No secret ID, no aws
+	if len(secretID) == 0 {
+		return nil
+	}
+
+	// Let's form AWS default config
+	conf, err := awsConfig.LoadDefaultConfig(ctx)
+	if err != nil {
+		return fmt.Errorf("can't load AWS Secrets Manager config: %s", err)
+	}
+
+	conf.Region = defaultAWSRegion
+	if len(secretRegion) > 0 {
+		conf.Region = secretRegion
+	}
+
+	// Let's create secrets manager client
+	client := secretsmanager.NewFromConfig(conf)
+
+	input := secretsmanager.GetSecretValueInput{SecretId: aws.String(secretID)}
+	if len(secretVersionID) > 0 {
+		input.VersionId = aws.String(secretVersionID)
+	} else if len(secretVersionStage) > 0 {
+		input.VersionStage = aws.String(secretVersionStage)
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	defer cancel()
+
+	output, err := client.GetSecretValue(ctx, &input)
+	if err != nil {
+		return fmt.Errorf("can't retrieve config from AWS Secrets Manager: %s", err)
+	}
+
+	// No secret string, failed to initialize secrets manager, return
+	if output.SecretString == nil {
+		return nil
+	}
+
+	return unmarshalEnv(*output.SecretString, "AWS Secrets Manager")
+}
+
+// loadAWSSystemManagerParams loads environment variables from AWS System Manager
+func loadAWSSystemManagerParams(ctx context.Context) error {
+	var paramsPath, paramsRegion string
+
+	String(&paramsPath, IMGPROXY_ENV_AWS_SSM_PARAMETERS_PATH)
+	String(&paramsRegion, IMGPROXY_ENV_AWS_SSM_PARAMETERS_REGION)
+
+	// Path is not set: can't use SSM
+	if len(paramsPath) == 0 {
+		return nil
+	}
+
+	conf, err := awsConfig.LoadDefaultConfig(ctx)
+	if err != nil {
+		return fmt.Errorf("can't load AWS SSM config: %s", err)
+	}
+
+	conf.Region = defaultAWSRegion
+	if len(paramsRegion) != 0 {
+		conf.Region = paramsRegion
+	}
+
+	// Let's create SSM client
+	client := ssm.NewFromConfig(conf)
+
+	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	defer cancel()
+
+	var nextToken *string
+
+	for {
+		input := ssm.GetParametersByPathInput{
+			Path:           aws.String(paramsPath),
+			WithDecryption: aws.Bool(true),
+			NextToken:      nextToken,
+		}
+
+		output, err := client.GetParametersByPath(ctx, &input)
+		if err != nil {
+			return fmt.Errorf("can't retrieve parameters from AWS SSM: %s", err)
+		}
+
+		for _, p := range output.Parameters {
+			if p.Name == nil || p.Value == nil {
+				continue
+			}
+
+			if p.DataType == nil || *p.DataType != "text" {
+				continue
+			}
+
+			name := *p.Name
+
+			env := strings.ReplaceAll(
+				strings.TrimPrefix(strings.TrimPrefix(name, paramsPath), "/"),
+				"/", "_",
+			)
+
+			if err = os.Setenv(env, *p.Value); err != nil {
+				return fmt.Errorf("can't set %s env variable from AWS SSM: %s", env, err)
+			}
+		}
+
+		if nextToken = output.NextToken; nextToken == nil {
+			break
+		}
+	}
+
+	return nil
+}

+ 70 - 0
env/desc.go

@@ -0,0 +1,70 @@
+package env
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+)
+
+// Desc describes an environment variable
+type Desc struct {
+	Name   string
+	Format string
+}
+
+// Describe creates a new EnvDesc
+func Describe(name string, format string) Desc {
+	return Desc{
+		Name:   name,
+		Format: format,
+	}
+}
+
+// Getenv returns the value of the env variable
+func (d Desc) Get() (string, bool) {
+	value := os.Getenv(d.Name)
+	return value, len(value) > 0
+}
+
+// Warn logs a warning with the env var details
+func (d Desc) Warn(msg string, args ...any) {
+	v, _ := d.Get()
+	args = append(args, "name", d.Name, "format", d.Format, "value", v)
+
+	slog.Warn(msg, args...)
+}
+
+// Errorf formats an error message for invalid env var
+func (d Desc) Errorf(msg string, args ...any) error {
+	return fmt.Errorf(
+		"invalid %s value (format: %s): %s",
+		d.Name,
+		d.Format,
+		fmt.Sprintf(msg, args...),
+	)
+}
+
+// WarnParseError logs a warning when an env var fails to parse
+func (d Desc) ErrorParse(err error) error {
+	return d.Errorf("failed to parse: %s", err)
+}
+
+// ErrorEmpty formats an error message for empty env var
+func (d Desc) ErrorEmpty() error {
+	return d.Errorf("cannot be empty")
+}
+
+// ErrorRange formats an error message for out of range env var
+func (d Desc) ErrorRange() error {
+	return d.Errorf("out of range")
+}
+
+// ErrorZeroOrLess formats an error message for zero or less env var
+func (d Desc) ErrorZeroOrNegative() error {
+	return d.Errorf("cannot be zero or negative")
+}
+
+// ErrorNegative formats an error message for negative env var
+func (d Desc) ErrorNegative() error {
+	return d.Errorf("cannot be negative")
+}

+ 78 - 0
env/gcp.go

@@ -0,0 +1,78 @@
+package env
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	secretmanager "cloud.google.com/go/secretmanager/apiv1"
+	"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
+	"google.golang.org/api/option"
+)
+
+var (
+	IMGPROXY_ENV_GCP_SECRET_ID         = Describe("IMGPROXY_ENV_GCP_SECRET_ID", "string")
+	IMGPROXY_ENV_GCP_SECRET_VERSION_ID = Describe("IMGPROXY_ENV_GCP_SECRET_VERSION_ID", "string")
+	IMGPROXY_ENV_GCP_SECRET_PROJECT_ID = Describe("IMGPROXY_ENV_GCP_SECRET_PROJECT_ID", "string")
+	IMGPROXY_ENV_GCP_KEY               = Describe("IMGPROXY_ENV_GCP_KEY", "JSON string")
+)
+
+func loadGCPSecret(ctx context.Context) error {
+	var secretID, secretVersion, secretProject, secretKey string
+
+	String(&secretID, IMGPROXY_ENV_GCP_SECRET_ID)
+	String(&secretVersion, IMGPROXY_ENV_GCP_SECRET_VERSION_ID)
+	String(&secretProject, IMGPROXY_ENV_GCP_SECRET_PROJECT_ID)
+	String(&secretKey, IMGPROXY_ENV_GCP_KEY)
+
+	if len(secretID) == 0 {
+		return nil
+	}
+
+	if len(secretVersion) == 0 {
+		secretVersion = "latest"
+	}
+
+	var (
+		client *secretmanager.Client
+		err    error
+	)
+
+	ctx, ctxcancel := context.WithTimeout(ctx, time.Minute)
+	defer ctxcancel()
+
+	opts := []option.ClientOption{}
+
+	if len(secretKey) > 0 {
+		opts = append(opts, option.WithCredentialsJSON([]byte(secretKey)))
+	}
+
+	client, err = secretmanager.NewClient(ctx, opts...)
+
+	if err != nil {
+		return fmt.Errorf("can't create Google Cloud Secret Manager client: %s", err)
+	}
+
+	req := secretmanagerpb.AccessSecretVersionRequest{
+		Name: fmt.Sprintf("projects/%s/secrets/%s/versions/%s", secretProject, secretID, secretVersion),
+	}
+
+	resp, err := client.AccessSecretVersion(ctx, &req)
+	if err != nil {
+		return fmt.Errorf("can't get Google Cloud Secret Manager secret: %s", err)
+	}
+
+	payload := resp.GetPayload()
+	if payload == nil {
+		return errors.New("can't get Google Cloud Secret Manager secret: payload is empty")
+	}
+
+	data := payload.GetData()
+
+	if len(data) == 0 {
+		return nil
+	}
+
+	return unmarshalEnv(string(data), "GCP Secret Manager")
+}

+ 75 - 0
env/load.go

@@ -0,0 +1,75 @@
+package env
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/DarthSim/godotenv"
+)
+
+var (
+	IMGPROXY_ENV_LOCAL_FILE_PATH = Describe("IMGPROXY_ENV_LOCAL_FILE_PATH", "path")
+)
+
+// Load loads environment variables from various sources
+func Load(ctx context.Context) error {
+	if err := loadAWSSecret(ctx); err != nil {
+		return err
+	}
+
+	if err := loadAWSSystemManagerParams(ctx); err != nil {
+		return err
+	}
+
+	if err := loadGCPSecret(ctx); err != nil {
+		return err
+	}
+
+	if err := loadLocalFile(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// loadLocalFile loads environment variables from a local file if IMGPROXY_ENV_LOCAL_FILE_PATH is set
+func loadLocalFile() error {
+	var path string
+
+	String(&path, IMGPROXY_ENV_LOCAL_FILE_PATH)
+
+	if len(path) == 0 {
+		return nil
+	}
+
+	// Read the local environment file
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("can't read local environment file: %s", err)
+	}
+
+	// If the file is empty, nothing to load
+	if len(data) == 0 {
+		return nil
+	}
+
+	return unmarshalEnv(string(data), "local file")
+}
+
+// unmarshalEnv loads environment variables from a string to process environment
+func unmarshalEnv(env, source string) error {
+	// Parse the secret string as env variables and set them
+	envmap, err := godotenv.Unmarshal(env)
+	if err != nil {
+		return fmt.Errorf("can't parse config from %s: %s", source, err)
+	}
+
+	for k, v := range envmap {
+		if err = os.Setenv(k, v); err != nil {
+			return fmt.Errorf("can't set %s env variable from %s: %s", k, source, err)
+		}
+	}
+
+	return nil
+}

+ 302 - 0
env/parsers.go

@@ -0,0 +1,302 @@
+package env
+
+import (
+	"bufio"
+	"encoding/hex"
+	"os"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/imagetype"
+)
+
+// Int parses an integer from the environment variable
+func Int(i *int, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	value, err := strconv.Atoi(env)
+	if err != nil {
+		return desc.ErrorParse(err)
+	}
+	*i = value
+
+	return nil
+}
+
+// Float parses a float64 value from the environment variable
+func Float(i *float64, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	value, err := strconv.ParseFloat(env, 64)
+	if err != nil {
+		return desc.ErrorParse(err)
+	}
+	*i = value
+
+	return nil
+}
+
+// MegaInt parses a "megascale" integer from the environment variable
+func MegaInt(f *int, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	value, err := strconv.ParseFloat(env, 64)
+	if err != nil {
+		return desc.ErrorParse(err)
+	}
+	*f = int(value) * 1000000
+
+	return nil
+}
+
+// Duration parses a duration (in seconds) from the environment variable
+func Duration(d *time.Duration, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	value, err := strconv.Atoi(env)
+	if err != nil {
+		return desc.ErrorParse(err)
+	}
+	*d = time.Duration(value) * time.Second
+
+	return nil
+}
+
+// String sets the string from the environment variable. Empty value is allowed.
+func String(s *string, desc Desc) error {
+	if env, ok := desc.Get(); ok {
+		*s = env
+	}
+
+	return nil
+}
+
+// Bool parses a boolean from the environment variable
+func Bool(b *bool, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	value, err := strconv.ParseBool(env)
+	if err != nil {
+		return desc.ErrorParse(err)
+	}
+	*b = value
+
+	return nil
+}
+
+// StringSliceSep parses a string slice from the environment variable, using the given separator
+func StringSliceSep(s *[]string, desc Desc, sep string) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	parts := strings.Split(env, sep)
+
+	for i, p := range parts {
+		parts[i] = strings.TrimSpace(p)
+	}
+
+	*s = parts
+
+	return nil
+}
+
+// StringSliceFile parses a string slice from a file, one entry per line
+func StringSliceFile(s *[]string, desc Desc, path string) error {
+	if len(path) == 0 {
+		return nil
+	}
+
+	f, err := os.Open(path)
+	if err != nil {
+		return desc.Errorf("can't open file %s", path)
+	}
+	defer f.Close()
+
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		str := strings.TrimSpace(scanner.Text())
+		if len(str) == 0 || strings.HasPrefix(str, "#") {
+			continue
+		}
+
+		*s = append(*s, str)
+	}
+
+	if err := scanner.Err(); err != nil {
+		return desc.Errorf("failed to read presets file: %s", err)
+	}
+
+	return nil
+}
+
+// StringSlice parses a string slice from the environment variable, using comma as a separator
+func StringSlice(s *[]string, desc Desc) error {
+	StringSliceSep(s, desc, ",")
+	return nil
+}
+
+// URLPath parses and normalizes a URL path from the environment variable
+func URLPath(s *string, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	if i := strings.IndexByte(env, '?'); i >= 0 {
+		env = env[:i]
+	}
+	if i := strings.IndexByte(env, '#'); i >= 0 {
+		env = env[:i]
+	}
+	if len(env) > 0 && env[len(env)-1] == '/' {
+		env = env[:len(env)-1]
+	}
+	if len(env) > 0 && env[0] != '/' {
+		env = "/" + env
+	}
+
+	*s = env
+
+	return nil
+}
+
+// ImageTypes parses a slice of image types from the environment variable
+func ImageTypes(it *[]imagetype.Type, desc Desc) error {
+	// Get image types from environment variable
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	parts := strings.Split(env, ",")
+	*it = make([]imagetype.Type, 0, len(parts))
+
+	for _, p := range parts {
+		part := strings.TrimSpace(p)
+
+		// For every part passed through the environment variable,
+		// check if it matches any of the image types defined in
+		// the imagetype package or return error.
+		t, ok := imagetype.GetTypeByName(part)
+		if !ok {
+			return desc.Errorf("unknown image format: %s", part)
+		}
+		*it = append(*it, t)
+	}
+
+	return nil
+}
+
+// ImageTypesQuality parses a string of format=queality pairs
+func ImageTypesQuality(m map[imagetype.Type]int, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	parts := strings.SplitSeq(env, ",")
+
+	for p := range parts {
+		i := strings.Index(p, "=")
+		if i < 0 {
+			return desc.Errorf("invalid format quality string: %s", p)
+		}
+
+		// Split the string into image type and quality
+		imgtypeStr, qStr := strings.TrimSpace(p[:i]), strings.TrimSpace(p[i+1:])
+
+		// Check if quality is a valid integer
+		q, err := strconv.Atoi(qStr)
+		if err != nil || q <= 0 || q > 100 {
+			return desc.Errorf("invalid quality: %s", p)
+		}
+
+		t, ok := imagetype.GetTypeByName(imgtypeStr)
+		if !ok {
+			return desc.Errorf("unknown image format: %s", imgtypeStr)
+		}
+
+		m[t] = q
+	}
+
+	return nil
+}
+
+// Patterns parses a slice of regexps from the environment variable
+func Patterns(s *[]*regexp.Regexp, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	parts := strings.Split(env, ",")
+	result := make([]*regexp.Regexp, len(parts))
+
+	for i, p := range parts {
+		result[i] = RegexpFromPattern(strings.TrimSpace(p))
+	}
+
+	*s = result
+
+	return nil
+}
+
+// RegexpFromPattern creates a regexp from a wildcard pattern
+func RegexpFromPattern(pattern string) *regexp.Regexp {
+	var result strings.Builder
+	// Perform prefix matching
+	result.WriteString("^")
+	for i, part := range strings.Split(pattern, "*") {
+		// Add a regexp match all without slashes for each wildcard character
+		if i > 0 {
+			result.WriteString("([^/]*)")
+		}
+
+		// Quote other parts of the pattern
+		result.WriteString(regexp.QuoteMeta(part))
+	}
+	// It is safe to use regexp.MustCompile since the expression is always valid
+	return regexp.MustCompile(result.String())
+}
+
+// HexSlice parses a slice of hex-encoded byte slices from the environment variable
+func HexSlice(b *[][]byte, desc Desc) error {
+	var err error
+
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	parts := strings.Split(env, ",")
+	keys := make([][]byte, len(parts))
+
+	for i, part := range parts {
+		if keys[i], err = hex.DecodeString(part); err != nil {
+			return desc.Errorf("%s expected to be hex-encoded string", part)
+		}
+	}
+
+	*b = keys
+
+	return nil
+}

+ 39 - 0
env/url_replacements.go

@@ -0,0 +1,39 @@
+package env
+
+import (
+	"regexp"
+	"strings"
+)
+
+// URLReplacement represents a URL replacement configuration
+type URLReplacement struct {
+	Regexp      *regexp.Regexp
+	Replacement string
+}
+
+// URLReplacements parses URL replacements from the environment variable
+func URLReplacements(s *[]URLReplacement, desc Desc) error {
+	value, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	ss := []URLReplacement(nil)
+
+	keyvalues := strings.Split(value, ";")
+
+	for _, keyvalue := range keyvalues {
+		parts := strings.SplitN(keyvalue, "=", 2)
+		if len(parts) != 2 {
+			return desc.Errorf("invalid key/value: %s", keyvalue)
+		}
+		ss = append(ss, URLReplacement{
+			Regexp:      RegexpFromPattern(parts[0]),
+			Replacement: parts[1],
+		})
+	}
+
+	*s = ss
+
+	return nil
+}

+ 25 - 11
fetcher/config.go

@@ -2,14 +2,21 @@ package fetcher
 
 import (
 	"errors"
+	"strings"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport"
 	"github.com/imgproxy/imgproxy/v3/version"
 )
 
+var (
+	IMGPROXY_USER_AGENT       = env.Describe("IMGPROXY_USER_AGENT", "non-empty string")
+	IMGPROXY_DOWNLOAD_TIMEOUT = env.Describe("IMGPROXY_DOWNLOAD_TIMEOUT", "seconds => 0")
+	IMGPROXY_MAX_REDIRECTS    = env.Describe("IMGPROXY_MAX_REDIRECTS", "integer > 0")
+)
+
 // Config holds the configuration for the image fetcher.
 type Config struct {
 	// UserAgent is the User-Agent header to use when fetching images.
@@ -39,26 +46,33 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.UserAgent = config.UserAgent
-	c.DownloadTimeout = time.Duration(config.DownloadTimeout) * time.Second
-	c.MaxRedirects = config.MaxRedirects
+	_, trErr := transport.LoadConfigFromEnv(&c.Transport)
 
-	_, err := transport.LoadConfigFromEnv(&c.Transport)
-	if err != nil {
-		return nil, err
-	}
+	err := errors.Join(
+		trErr,
+		env.String(&c.UserAgent, IMGPROXY_USER_AGENT),
+		env.Duration(&c.DownloadTimeout, IMGPROXY_DOWNLOAD_TIMEOUT),
+		env.Int(&c.MaxRedirects, IMGPROXY_MAX_REDIRECTS),
+	)
 
-	return c, nil
+	// Set the current version in the User-Agent string
+	c.UserAgent = strings.ReplaceAll(c.UserAgent, "%current_version", version.Version)
+
+	return c, err
 }
 
 // Validate checks config for errors
 func (c *Config) Validate() error {
 	if len(c.UserAgent) == 0 {
-		return errors.New("user agent cannot be empty")
+		return IMGPROXY_USER_AGENT.ErrorEmpty()
 	}
 
 	if c.DownloadTimeout <= 0 {
-		return errors.New("download timeout must be greater than 0")
+		return IMGPROXY_DOWNLOAD_TIMEOUT.ErrorZeroOrNegative()
+	}
+
+	if c.MaxRedirects <= 0 {
+		return IMGPROXY_MAX_REDIRECTS.ErrorZeroOrNegative()
 	}
 
 	return nil

+ 1 - 3
fetcher/transport/azure/azure_test.go

@@ -8,7 +8,6 @@ import (
 
 	"github.com/stretchr/testify/suite"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/generichttp"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/logger"
@@ -17,7 +16,7 @@ import (
 type AzureTestSuite struct {
 	suite.Suite
 
-	server       *httptest.Server
+	server       *httptest.Server // TODO: use testutils.TestServer
 	transport    http.RoundTripper
 	etag         string
 	lastModified time.Time
@@ -58,7 +57,6 @@ func (s *AzureTestSuite) SetupSuite() {
 
 func (s *AzureTestSuite) TearDownSuite() {
 	s.server.Close()
-	config.IgnoreSslVerification = false
 	logger.Unmute()
 }
 

+ 15 - 7
fetcher/transport/azure/config.go

@@ -1,10 +1,16 @@
 package azure
 
 import (
-	"fmt"
+	"errors"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_ABS_NAME     = env.Describe("IMGPROXY_ABS_NAME", "string")
+	IMGPROXY_ABS_ENDPOINT = env.Describe("IMGPROXY_ABS_ENDPOINT", "string")
+	IMGPROXY_ABS_KEY      = env.Describe("IMGPROXY_ABS_KEY", "string")
 )
 
 // Config holds the configuration for Azure Blob Storage transport
@@ -27,17 +33,19 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.Name = config.ABSName
-	c.Endpoint = config.ABSEndpoint
-	c.Key = config.ABSKey
+	err := errors.Join(
+		env.String(&c.Name, IMGPROXY_ABS_NAME),
+		env.String(&c.Endpoint, IMGPROXY_ABS_ENDPOINT),
+		env.String(&c.Key, IMGPROXY_ABS_KEY),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks if the configuration is valid
 func (c *Config) Validate() error {
 	if len(c.Name) == 0 {
-		return fmt.Errorf("azure account name must be set")
+		return IMGPROXY_ABS_NAME.ErrorEmpty()
 	}
 
 	return nil

+ 31 - 33
fetcher/transport/config.go

@@ -3,8 +3,10 @@
 package transport
 
 import (
-	"github.com/imgproxy/imgproxy/v3/config"
+	"errors"
+
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/azure"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/fs"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/gcs"
@@ -13,6 +15,13 @@ import (
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/swift"
 )
 
+var (
+	IMGPROXY_USE_ABS   = env.Describe("IMGPROXY_USE_ABS", "boolean")
+	IMGPROXY_USE_GCS   = env.Describe("IMGPROXY_GCS_ENABLED", "boolean")
+	IMGPROXY_USE_S3    = env.Describe("IMGPROXY_USE_S3", "boolean")
+	IMGPROXY_USE_SWIFT = env.Describe("IMGPROXY_USE_SWIFT", "boolean")
+)
+
 // Config represents configuration of the transport package
 type Config struct {
 	HTTP generichttp.Config
@@ -52,38 +61,27 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	var err error
-
-	if _, err = generichttp.LoadConfigFromEnv(&c.HTTP); err != nil {
-		return nil, err
-	}
-
-	if _, err = fs.LoadConfigFromEnv(&c.Local); err != nil {
-		return nil, err
-	}
-
-	if _, err = azure.LoadConfigFromEnv(&c.ABS); err != nil {
-		return nil, err
-	}
-
-	if _, err = gcs.LoadConfigFromEnv(&c.GCS); err != nil {
-		return nil, err
-	}
-
-	if _, err = s3.LoadConfigFromEnv(&c.S3); err != nil {
-		return nil, err
-	}
-
-	if _, err = swift.LoadConfigFromEnv(&c.Swift); err != nil {
-		return nil, err
-	}
-
-	c.ABSEnabled = config.ABSEnabled
-	c.GCSEnabled = config.GCSEnabled
-	c.S3Enabled = config.S3Enabled
-	c.SwiftEnabled = config.SwiftEnabled
-
-	return c, nil
+	_, genericErr := generichttp.LoadConfigFromEnv(&c.HTTP)
+	_, localErr := fs.LoadConfigFromEnv(&c.Local)
+	_, azureErr := azure.LoadConfigFromEnv(&c.ABS)
+	_, gcsErr := gcs.LoadConfigFromEnv(&c.GCS)
+	_, s3Err := s3.LoadConfigFromEnv(&c.S3)
+	_, swiftErr := swift.LoadConfigFromEnv(&c.Swift)
+
+	err := errors.Join(
+		genericErr,
+		localErr,
+		azureErr,
+		gcsErr,
+		s3Err,
+		swiftErr,
+		env.Bool(&c.ABSEnabled, IMGPROXY_USE_ABS),
+		env.Bool(&c.GCSEnabled, IMGPROXY_USE_GCS),
+		env.Bool(&c.S3Enabled, IMGPROXY_USE_S3),
+		env.Bool(&c.SwiftEnabled, IMGPROXY_USE_SWIFT),
+	)
+
+	return c, err
 }
 
 func (c *Config) Validate() error {

+ 14 - 11
fetcher/transport/fs/config.go

@@ -1,12 +1,15 @@
 package fs
 
 import (
-	"errors"
-	"fmt"
+	"log/slog"
 	"os"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_LOCAL_FILESYSTEM_ROOT = env.Describe("IMGPROXY_LOCAL_FILESYSTEM_ROOT", "path")
 )
 
 // Config holds the configuration for local file system transport
@@ -25,30 +28,30 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.Root = config.LocalFileSystemRoot
+	err := env.String(&c.Root, IMGPROXY_LOCAL_FILESYSTEM_ROOT)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks if the configuration is valid
 func (c *Config) Validate() error {
+	e := IMGPROXY_LOCAL_FILESYSTEM_ROOT
+
 	if c.Root == "" {
-		return errors.New("local file system root shold not be blank")
+		return e.ErrorEmpty()
 	}
 
 	stat, err := os.Stat(c.Root)
 	if err != nil {
-		return fmt.Errorf("cannot use local directory: %s", err)
+		return e.Errorf("cannot use local directory: %s", err)
 	}
 
 	if !stat.IsDir() {
-		return fmt.Errorf("cannot use local directory: not a directory")
+		return e.Errorf("cannot use local directory: not a directory")
 	}
 
 	if c.Root == "/" {
-		// Warning: exposing root is unsafe
-		// TODO: Move this somewhere to the instance checks (?)
-		fmt.Println("Warning: Exposing root via IMGPROXY_LOCAL_FILESYSTEM_ROOT is unsafe")
+		slog.Warn("Exposing root via IMGPROXY_LOCAL_FILESYSTEM_ROOT is unsafe")
 	}
 
 	return nil

+ 6 - 3
fetcher/transport/fs/fs.go

@@ -23,9 +23,12 @@ type transport struct {
 	fs http.Dir
 }
 
-func New(config *Config) transport {
-	// TODO: VALIDATE HERE
-	return transport{fs: http.Dir(config.Root)}
+func New(config *Config) (transport, error) {
+	if err := config.Validate(); err != nil {
+		return transport{}, err
+	}
+
+	return transport{fs: http.Dir(config.Root)}, nil
 }
 
 func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error) {

+ 1 - 1
fetcher/transport/fs/fs_test.go

@@ -31,7 +31,7 @@ func (s *FsTestSuite) SetupSuite() {
 
 	s.etag = BuildEtag("/test1.png", fi)
 	s.modTime = fi.ModTime()
-	s.transport = New(&Config{Root: fsRoot})
+	s.transport, _ = New(&Config{Root: fsRoot})
 }
 
 func (s *FsTestSuite) TestRoundTripWithETagEnabled() {

+ 13 - 4
fetcher/transport/gcs/config.go

@@ -1,8 +1,15 @@
 package gcs
 
 import (
-	"github.com/imgproxy/imgproxy/v3/config"
+	"errors"
+
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_GCS_KEY      = env.Describe("IMGPROXY_GCS_KEY", "string")
+	IMGPROXY_GCS_ENDPOINT = env.Describe("IMGPROXY_GCS_ENDPOINT", "string")
 )
 
 // Config holds the configuration for Google Cloud Storage transport
@@ -23,10 +30,12 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.Key = config.GCSKey
-	c.Endpoint = config.GCSEndpoint
+	err := errors.Join(
+		env.String(&c.Key, IMGPROXY_GCS_KEY),
+		env.String(&c.Endpoint, IMGPROXY_GCS_ENDPOINT),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks the configuration for errors

+ 23 - 9
fetcher/transport/generichttp/config.go

@@ -1,11 +1,19 @@
 package generichttp
 
 import (
-	"fmt"
+	"errors"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT         = env.Describe("IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT", "seconds => 0")
+	IMGPROXY_IGNORE_SSL_VERIFICATION           = env.Describe("IMGPROXY_IGNORE_SSL_VERIFICATION", "boolean")
+	IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES   = env.Describe("IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES", "boolean")
+	IMGPROXY_ALLOW_LINK_LOCAL_SOURCE_ADDRESSES = env.Describe("IMGPROXY_ALLOW_LINK_LOCAL_SOURCE_ADDRESSES", "boolean")
+	IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES    = env.Describe("IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES", "boolean")
 )
 
 // Config holds the configuration for the generic HTTP transport
@@ -32,19 +40,25 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.ClientKeepAliveTimeout = time.Duration(config.ClientKeepAliveTimeout) * time.Second
-	c.IgnoreSslVerification = config.IgnoreSslVerification
-	c.AllowLinkLocalSourceAddresses = config.AllowLinkLocalSourceAddresses
-	c.AllowLoopbackSourceAddresses = config.AllowLoopbackSourceAddresses
-	c.AllowPrivateSourceAddresses = config.AllowPrivateSourceAddresses
+	err := errors.Join(
+		env.Duration(&c.ClientKeepAliveTimeout, IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT),
+		env.Bool(&c.IgnoreSslVerification, IMGPROXY_IGNORE_SSL_VERIFICATION),
+		env.Bool(&c.AllowLinkLocalSourceAddresses, IMGPROXY_ALLOW_LINK_LOCAL_SOURCE_ADDRESSES),
+		env.Bool(&c.AllowLoopbackSourceAddresses, IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES),
+		env.Bool(&c.AllowPrivateSourceAddresses, IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks the configuration for errors
 func (c *Config) Validate() error {
 	if c.ClientKeepAliveTimeout < 0 {
-		return fmt.Errorf("client KeepAlive timeout should be greater than or equal to 0, now - %d", c.ClientKeepAliveTimeout)
+		return IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT.ErrorZeroOrNegative()
+	}
+
+	if c.IgnoreSslVerification {
+		IMGPROXY_IGNORE_SSL_VERIFICATION.Warn("ignoring SSL verification is very unsafe") // ⎈
 	}
 
 	return nil

+ 21 - 8
fetcher/transport/s3/config.go

@@ -1,8 +1,19 @@
 package s3
 
 import (
-	"github.com/imgproxy/imgproxy/v3/config"
+	"errors"
+
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_S3_REGION                    = env.Describe("IMGPROXY_S3_REGION", "string")
+	IMGPROXY_S3_ENDPOINT                  = env.Describe("IMGPROXY_S3_ENDPOINT", "string")
+	IMGPROXY_S3_ENDPOINT_USE_PATH_STYLE   = env.Describe("IMGPROXY_S3_ENDPOINT_USE_PATH_STYLE", "boolean")
+	IMGPROXY_S3_ASSUME_ROLE_ARN           = env.Describe("IMGPROXY_S3_ASSUME_ROLE_ARN", "string")
+	IMGPROXY_S3_ASSUME_ROLE_EXTERNAL_ID   = env.Describe("IMGPROXY_S3_ASSUME_ROLE_EXTERNAL_ID", "string")
+	IMGPROXY_S3_DECRYPTION_CLIENT_ENABLED = env.Describe("IMGPROXY_S3_DECRYPTION_CLIENT_ENABLED", "boolean")
 )
 
 // Config holds the configuration for S3 transport
@@ -31,14 +42,16 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.Region = config.S3Region
-	c.Endpoint = config.S3Endpoint
-	c.EndpointUsePathStyle = config.S3EndpointUsePathStyle
-	c.AssumeRoleArn = config.S3AssumeRoleArn
-	c.AssumeRoleExternalID = config.S3AssumeRoleExternalID
-	c.DecryptionClientEnabled = config.S3DecryptionClientEnabled
+	err := errors.Join(
+		env.String(&c.Region, IMGPROXY_S3_REGION),
+		env.String(&c.Endpoint, IMGPROXY_S3_ENDPOINT),
+		env.Bool(&c.EndpointUsePathStyle, IMGPROXY_S3_ENDPOINT_USE_PATH_STYLE),
+		env.String(&c.AssumeRoleArn, IMGPROXY_S3_ASSUME_ROLE_ARN),
+		env.String(&c.AssumeRoleExternalID, IMGPROXY_S3_ASSUME_ROLE_EXTERNAL_ID),
+		env.Bool(&c.DecryptionClientEnabled, IMGPROXY_S3_DECRYPTION_CLIENT_ENABLED),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks the configuration for errors

+ 24 - 10
fetcher/transport/swift/config.go

@@ -1,10 +1,22 @@
 package swift
 
 import (
+	"errors"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_SWIFT_USERNAME                = env.Describe("IMGPROXY_SWIFT_USERNAME", "string")
+	IMGPROXY_SWIFT_API_KEY                 = env.Describe("IMGPROXY_SWIFT_API_KEY", "string")
+	IMGPROXY_SWIFT_AUTH_URL                = env.Describe("IMGPROXY_SWIFT_AUTH_URL", "string")
+	IMGPROXY_SWIFT_DOMAIN                  = env.Describe("IMGPROXY_SWIFT_DOMAIN", "string")
+	IMGPROXY_SWIFT_TENANT                  = env.Describe("IMGPROXY_SWIFT_TENANT", "string")
+	IMGPROXY_SWIFT_AUTH_VERSION            = env.Describe("IMGPROXY_SWIFT_AUTH_VERSION", "number")
+	IMGPROXY_SWIFT_CONNECT_TIMEOUT_SECONDS = env.Describe("IMGPROXY_SWIFT_CONNECT_TIMEOUT_SECONDS", "number")
+	IMGPROXY_SWIFT_TIMEOUT_SECONDS         = env.Describe("IMGPROXY_SWIFT_TIMEOUT_SECONDS", "number")
 )
 
 // Config holds the configuration for Swift transport
@@ -37,16 +49,18 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.Username = config.SwiftUsername
-	c.APIKey = config.SwiftAPIKey
-	c.AuthURL = config.SwiftAuthURL
-	c.Domain = config.SwiftDomain
-	c.Tenant = config.SwiftTenant
-	c.AuthVersion = config.SwiftAuthVersion
-	c.ConnectTimeout = time.Duration(config.SwiftConnectTimeoutSeconds) * time.Second
-	c.Timeout = time.Duration(config.SwiftTimeoutSeconds) * time.Second
+	err := errors.Join(
+		env.String(&c.Username, IMGPROXY_SWIFT_USERNAME),
+		env.String(&c.APIKey, IMGPROXY_SWIFT_API_KEY),
+		env.String(&c.AuthURL, IMGPROXY_SWIFT_AUTH_URL),
+		env.String(&c.Domain, IMGPROXY_SWIFT_DOMAIN),
+		env.String(&c.Tenant, IMGPROXY_SWIFT_TENANT),
+		env.Int(&c.AuthVersion, IMGPROXY_SWIFT_AUTH_VERSION),
+		env.Duration(&c.ConnectTimeout, IMGPROXY_SWIFT_CONNECT_TIMEOUT_SECONDS),
+		env.Duration(&c.Timeout, IMGPROXY_SWIFT_TIMEOUT_SECONDS),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks the configuration for errors

+ 4 - 9
fetcher/transport/swift/swift.go

@@ -9,7 +9,6 @@ import (
 
 	"github.com/ncw/swift/v2"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/common"
 	"github.com/imgproxy/imgproxy/v3/fetcher/transport/notmodified"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
@@ -92,16 +91,12 @@ func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error)
 		return nil, ierrors.Wrap(err, 0, ierrors.WithPrefix("error opening object"))
 	}
 
-	if config.ETagEnabled {
-		if etag, ok := objectHeaders["Etag"]; ok {
-			header.Set("ETag", etag)
-		}
+	if etag, ok := objectHeaders["Etag"]; ok {
+		header.Set("ETag", etag)
 	}
 
-	if config.LastModifiedEnabled {
-		if lastModified, ok := objectHeaders["Last-Modified"]; ok {
-			header.Set("Last-Modified", lastModified)
-		}
+	if lastModified, ok := objectHeaders["Last-Modified"]; ok {
+		header.Set("Last-Modified", lastModified)
 	}
 
 	if resp := notmodified.Response(req, header); resp != nil {

+ 17 - 13
fetcher/transport/transport.go

@@ -77,39 +77,43 @@ func (t *Transport) registerAllProtocols() error {
 	}
 
 	if t.config.Local.Root != "" {
-		t.RegisterProtocol("local", fsTransport.New(&t.config.Local))
+		p, err := fsTransport.New(&t.config.Local)
+		if err != nil {
+			return err
+		}
+		t.RegisterProtocol("local", p)
 	}
 
 	if t.config.S3Enabled {
-		if tr, err := s3Transport.New(&t.config.S3, transp); err != nil {
+		tr, err := s3Transport.New(&t.config.S3, transp)
+		if err != nil {
 			return err
-		} else {
-			t.RegisterProtocol("s3", tr)
 		}
+		t.RegisterProtocol("s3", tr)
 	}
 
 	if t.config.GCSEnabled {
-		if tr, err := gcsTransport.New(&t.config.GCS, transp); err != nil {
+		tr, err := gcsTransport.New(&t.config.GCS, transp)
+		if err != nil {
 			return err
-		} else {
-			t.RegisterProtocol("gs", tr)
 		}
+		t.RegisterProtocol("gs", tr)
 	}
 
 	if t.config.ABSEnabled {
-		if tr, err := azureTransport.New(&t.config.ABS, transp); err != nil {
+		tr, err := azureTransport.New(&t.config.ABS, transp)
+		if err != nil {
 			return err
-		} else {
-			t.RegisterProtocol("abs", tr)
 		}
+		t.RegisterProtocol("abs", tr)
 	}
 
 	if t.config.SwiftEnabled {
-		if tr, err := swiftTransport.New(&t.config.Swift, transp); err != nil {
+		tr, err := swiftTransport.New(&t.config.Swift, transp)
+		if err != nil {
 			return err
-		} else {
-			t.RegisterProtocol("swift", tr)
 		}
+		t.RegisterProtocol("swift", tr)
 	}
 
 	return nil

+ 23 - 10
handlers/processing/config.go

@@ -4,8 +4,18 @@ import (
 	"errors"
 	"net/http"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_COOKIE_PASSTHROUGH        = env.Describe("IMGPROXY_COOKIE_PASSTHROUGH", "boolean")
+	IMGPROXY_REPORT_DOWNLOADING_ERRORS = env.Describe("IMGPROXY_REPORT_DOWNLOADING_ERRORS", "boolean")
+	IMGPROXY_LAST_MODIFIED_ENABLED     = env.Describe("IMGPROXY_LAST_MODIFIED_ENABLED", "boolean")
+	IMGPROXY_ETAG_ENABLED              = env.Describe("IMGPROXY_ETAG_ENABLED", "boolean")
+	IMGPROXY_REPORT_IO_ERRORS          = env.Describe("IMGPROXY_REPORT_IO_ERRORS", "boolean")
+	IMGPROXY_FALLBACK_IMAGE_HTTP_CODE  = env.Describe("IMGPROXY_FALLBACK_IMAGE_HTTP_CODE", "HTTP code")
+	IMGPROXY_ENABLE_DEBUG_HEADERS      = env.Describe("IMGPROXY_ENABLE_DEBUG_HEADERS", "boolean")
 )
 
 // Config represents handler config
@@ -36,21 +46,24 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.CookiePassthrough = config.CookiePassthrough
-	c.ReportDownloadingErrors = config.ReportDownloadingErrors
-	c.LastModifiedEnabled = config.LastModifiedEnabled
-	c.ETagEnabled = config.ETagEnabled
-	c.ReportIOErrors = config.ReportIOErrors
-	c.FallbackImageHTTPCode = config.FallbackImageHTTPCode
-	c.EnableDebugHeaders = config.EnableDebugHeaders
+	err := errors.Join(
+		env.Bool(&c.CookiePassthrough, IMGPROXY_COOKIE_PASSTHROUGH),
+		env.Bool(&c.ReportDownloadingErrors, IMGPROXY_REPORT_DOWNLOADING_ERRORS),
+		env.Bool(&c.LastModifiedEnabled, IMGPROXY_LAST_MODIFIED_ENABLED),
+		env.Bool(&c.ETagEnabled, IMGPROXY_ETAG_ENABLED),
+		env.Bool(&c.ReportIOErrors, IMGPROXY_REPORT_IO_ERRORS),
+		env.Int(&c.FallbackImageHTTPCode, IMGPROXY_FALLBACK_IMAGE_HTTP_CODE),
+		env.Bool(&c.EnableDebugHeaders, IMGPROXY_ENABLE_DEBUG_HEADERS),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks configuration values
 func (c *Config) Validate() error {
 	if c.FallbackImageHTTPCode != 0 && (c.FallbackImageHTTPCode < 100 || c.FallbackImageHTTPCode > 599) {
-		return errors.New("fallback image HTTP code should be between 100 and 599")
+		return IMGPROXY_FALLBACK_IMAGE_HTTP_CODE.Errorf("invalid")
 	}
+
 	return nil
 }

+ 10 - 3
handlers/stream/config.go

@@ -1,11 +1,18 @@
 package stream
 
 import (
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 )
 
+var (
+	// NOTE: processing handler has the similar variable. For now, we do not want
+	// to couple hanlders/processing and handlers/stream packages, so we duplicate it here.
+	// Discuss.
+	IMGPROXY_COOKIE_PASSTHROUGH = env.Describe("IMGPROXY_COOKIE_PASSTHROUGH", "boolean")
+)
+
 // Config represents the configuration for the image streamer
 type Config struct {
 	// CookiePassthrough indicates whether cookies should be passed through to the image response
@@ -43,9 +50,9 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.CookiePassthrough = config.CookiePassthrough
+	err := env.Bool(&c.CookiePassthrough, IMGPROXY_COOKIE_PASSTHROUGH)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks config for errors

+ 11 - 4
init.go

@@ -3,13 +3,14 @@
 package imgproxy
 
 import (
+	"context"
 	"fmt"
 	"log/slog"
 
 	"go.uber.org/automaxprocs/maxprocs"
 
 	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/config/loadenv"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/logger"
 	"github.com/imgproxy/imgproxy/v3/monitoring"
@@ -18,11 +19,16 @@ import (
 
 // Init performs the global resources initialization. This should be done once per process.
 func Init() error {
-	if err := loadenv.Load(); err != nil {
+	if err := env.Load(context.TODO()); err != nil {
 		return err
 	}
 
-	logCfg := logger.LoadConfigFromEnv(nil)
+	logCfg, logErr := logger.LoadConfigFromEnv(nil)
+	if logErr != nil {
+		return logErr
+	}
+
+	// Initialize logger as early as possible to log further initialization steps
 	if err := logger.Init(logCfg); err != nil {
 		return err
 	}
@@ -31,7 +37,8 @@ func Init() error {
 	// actually configuring ImgProxy instance because for now we use it as a source of truth.
 	// Will be removed once we move env var loading to imgproxy.go
 	if err := config.Configure(); err != nil {
-		return err
+		// we moved validations to specific config files, hence, no need to return err
+		slog.Warn("old config validation warning", "err", err)
 	}
 	// NOTE: End of temporary workaround.
 

+ 5 - 1
integration/main_test.go

@@ -9,7 +9,11 @@ import (
 
 // TestMain performs global setup/teardown for the integration tests.
 func TestMain(m *testing.M) {
-	imgproxy.Init()
+	err := imgproxy.Init()
+	if err != nil {
+		panic(err)
+	}
+
 	os.Exit(m.Run())
 	imgproxy.Shutdown()
 }

+ 18 - 6
logger/config.go

@@ -1,17 +1,23 @@
 package logger
 
 import (
+	"errors"
 	"log/slog"
 	"os"
 	"strings"
 
 	"github.com/mattn/go-isatty"
 
-	"github.com/imgproxy/imgproxy/v3/config/configurators"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/logger/syslog"
 )
 
+var (
+	IMGPROXY_LOG_FORMAT = env.Describe("IMGPROXY_LOG_FORMAT", "pretty|structured|json|gcp")
+	IMGPROXY_LOG_LEVEL  = env.Describe("IMGPROXY_LOG_LEVEL", "debug|info|warn|error")
+)
+
 type Config struct {
 	Level  slog.Leveler
 	Format Format
@@ -34,24 +40,30 @@ func NewDefaultConfig() Config {
 	return o
 }
 
-func LoadConfigFromEnv(o *Config) *Config {
+func LoadConfigFromEnv(o *Config) (*Config, error) {
 	o = ensure.Ensure(o, NewDefaultConfig)
 
 	var logFormat, logLevel string
-	configurators.String(&logFormat, "IMGPROXY_LOG_FORMAT")
-	configurators.String(&logLevel, "IMGPROXY_LOG_LEVEL")
+
+	_, slErr := syslog.LoadConfigFromEnv(&o.Syslog)
+
+	err := errors.Join(
+		slErr,
+		env.String(&logFormat, IMGPROXY_LOG_FORMAT),
+		env.String(&logLevel, IMGPROXY_LOG_LEVEL),
+	)
 
 	if logFormat != "" {
 		o.Format = parseFormat(logFormat)
 	}
+
 	if logLevel != "" {
 		o.Level = parseLevel(logLevel)
 	}
 
 	// Load syslog config
-	syslog.LoadConfigFromEnv(&o.Syslog)
 
-	return o
+	return o, err
 }
 
 func (c *Config) Validate() error {

+ 20 - 11
logger/syslog/config.go

@@ -6,8 +6,16 @@ import (
 	"log/slog"
 	"strings"
 
-	"github.com/imgproxy/imgproxy/v3/config/configurators"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_SYSLOG_ENABLE  = env.Describe("IMGPROXY_SYSLOG_ENABLE", "boolean")
+	IMGPROXY_SYSLOG_LEVEL   = env.Describe("IMGPROXY_SYSLOG_LEVEL", "debug|info|warn|error|crit")
+	IMGPROXY_SYSLOG_NETWORK = env.Describe("IMGPROXY_SYSLOG_NETWORK", "string")
+	IMGPROXY_SYSLOG_ADDRESS = env.Describe("IMGPROXY_SYSLOG_ADDRESS", "string")
+	IMGPROXY_SYSLOG_TAG     = env.Describe("IMGPROXY_SYSLOG_TAG", "string")
 )
 
 type Config struct {
@@ -26,23 +34,24 @@ func NewDefaultConfig() Config {
 	}
 }
 
-func LoadConfigFromEnv(c *Config) *Config {
+func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	configurators.Bool(&c.Enabled, "IMGPROXY_SYSLOG_ENABLE")
-
-	configurators.String(&c.Network, "IMGPROXY_SYSLOG_NETWORK")
-	configurators.String(&c.Addr, "IMGPROXY_SYSLOG_ADDRESS")
-	configurators.String(&c.Tag, "IMGPROXY_SYSLOG_TAG")
-
 	var levelStr string
-	configurators.String(&levelStr, "IMGPROXY_SYSLOG_LEVEL")
+
+	err := errors.Join(
+		env.Bool(&c.Enabled, IMGPROXY_SYSLOG_ENABLE),
+		env.String(&c.Network, IMGPROXY_SYSLOG_NETWORK),
+		env.String(&c.Addr, IMGPROXY_SYSLOG_ADDRESS),
+		env.String(&c.Tag, IMGPROXY_SYSLOG_TAG),
+		env.String(&levelStr, IMGPROXY_SYSLOG_LEVEL),
+	)
 
 	if levelStr != "" {
 		c.Level = parseLevel(levelStr)
 	}
 
-	return c
+	return c, err
 }
 
 func (c *Config) Validate() error {
@@ -51,7 +60,7 @@ func (c *Config) Validate() error {
 	}
 
 	if c.Network != "" && c.Addr == "" {
-		return errors.New("Syslog address is required if syslog network is set")
+		return errors.New("syslog address is required if syslog network is set")
 	}
 
 	return nil

+ 71 - 26
options/parser/config.go

@@ -2,14 +2,39 @@ package optionsparser
 
 import (
 	"errors"
-	"slices"
+	"os"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 )
 
-// URLReplacement represents a URL replacement configuration
-type URLReplacement = config.URLReplacement
+type URLReplacement = env.URLReplacement
+
+const (
+	PresetsFlagName = "presets" // --presets flag name
+)
+
+var (
+	IMGPROXY_PRESETS_SEPARATOR            = env.Describe("IMGPROXY_PRESETS_SEPARATOR", "string")
+	IMGPROXY_PRESETS                      = env.Describe("IMGPROXY_PRESETS", "separated list of strings (see IMGPROXY_PRESETS_SEPARATOR)")
+	IMGPROXY_ONLY_PRESETS                 = env.Describe("IMGPROXY_ONLY_PRESETS", "boolean")
+	IMGPROXY_ALLOWED_PROCESSING_OPTIONS   = env.Describe("IMGPROXY_ALLOWED_PROCESSING_OPTIONS", "comma-separated list of strings")
+	IMGPROXY_ALLOW_SECURITY_OPTIONS       = env.Describe("IMGPROXY_ALLOW_SECURITY_OPTIONS", "boolean")
+	IMGPROXY_AUTO_WEBP                    = env.Describe("IMGPROXY_AUTO_WEBP", "boolean")
+	IMGPROXY_ENFORCE_WEBP                 = env.Describe("IMGPROXY_ENFORCE_WEBP", "boolean")
+	IMGPROXY_AUTO_AVIF                    = env.Describe("IMGPROXY_AUTO_AVIF", "boolean")
+	IMGPROXY_ENFORCE_AVIF                 = env.Describe("IMGPROXY_ENFORCE_AVIF", "boolean")
+	IMGPROXY_AUTO_JXL                     = env.Describe("IMGPROXY_AUTO_JXL", "boolean")
+	IMGPROXY_ENFORCE_JXL                  = env.Describe("IMGPROXY_ENFORCE_JXL", "boolean")
+	IMGPROXY_ENABLE_CLIENT_HINTS          = env.Describe("IMGPROXY_ENABLE_CLIENT_HINTS", "boolean")
+	IMGPROXY_ARGUMENTS_SEPARATOR          = env.Describe("IMGPROXY_ARGUMENTS_SEPARATOR", "string")
+	IMGPROXY_BASE_URL                     = env.Describe("IMGPROXY_BASE_URL", "string")
+	IMGPROXY_URL_REPLACEMENTS             = env.Describe("IMGPROXY_URL_REPLACEMENTS", "comma-separated list of key=value pairs")
+	IMGPROXY_BASE64_URL_INCLUDES_FILENAME = env.Describe("IMGPROXY_BASE64_URL_INCLUDES_FILENAME", "boolean")
+
+	// Artificial env.desc for --presets flag
+	PRESETS_PATH = env.Describe("--"+PresetsFlagName, "path to presets file")
+)
 
 // Config represents the configuration for options processing
 type Config struct {
@@ -70,39 +95,59 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	// Presets configuration
-	c.Presets = slices.Clone(config.Presets)
-	c.OnlyPresets = config.OnlyPresets
+	sep := ","
+	var presetsPath string
+
+	// NOTE: Here is the workaround for reading --presets flag from CLI.
+	// Otherwise, we'd have to either store it in a global variable or
+	// pass cli.Command down the call stack.
+	for i, arg := range os.Args {
+		if arg == "--"+PresetsFlagName && i+1 < len(os.Args) {
+			presetsPath = os.Args[i+1]
+			break
+		}
+	}
 
-	// Security and validation
-	c.AllowedProcessingOptions = slices.Clone(config.AllowedProcessingOptions)
-	c.AllowSecurityOptions = config.AllowSecurityOptions
+	c.Presets = make([]string, 0)
+	c.URLReplacements = make([]URLReplacement, 0)
 
-	// Format preference and enforcement
-	c.AutoWebp = config.AutoWebp
-	c.EnforceWebp = config.EnforceWebp
-	c.AutoAvif = config.AutoAvif
-	c.EnforceAvif = config.EnforceAvif
-	c.AutoJxl = config.AutoJxl
-	c.EnforceJxl = config.EnforceJxl
+	err := errors.Join(
+		env.String(&sep, IMGPROXY_PRESETS_SEPARATOR),
+		env.StringSliceSep(&c.Presets, IMGPROXY_PRESETS, sep),
+		env.Bool(&c.OnlyPresets, IMGPROXY_ONLY_PRESETS),
 
-	// Client hints
-	c.EnableClientHints = config.EnableClientHints
+		// Security and validation
+		env.StringSlice(&c.AllowedProcessingOptions, IMGPROXY_ALLOWED_PROCESSING_OPTIONS),
+		env.Bool(&c.AllowSecurityOptions, IMGPROXY_ALLOW_SECURITY_OPTIONS),
 
-	// URL processing
-	c.ArgumentsSeparator = config.ArgumentsSeparator
-	c.BaseURL = config.BaseURL
-	c.URLReplacements = slices.Clone(config.URLReplacements)
-	c.Base64URLIncludesFilename = config.Base64URLIncludesFilename
+		// Format preference and enforcement
+		env.Bool(&c.AutoWebp, IMGPROXY_AUTO_WEBP),
+		env.Bool(&c.EnforceWebp, IMGPROXY_ENFORCE_WEBP),
+		env.Bool(&c.AutoAvif, IMGPROXY_AUTO_AVIF),
+		env.Bool(&c.EnforceAvif, IMGPROXY_ENFORCE_AVIF),
+		env.Bool(&c.AutoJxl, IMGPROXY_AUTO_JXL),
+		env.Bool(&c.EnforceJxl, IMGPROXY_ENFORCE_JXL),
+
+		// Client hints
+		env.Bool(&c.EnableClientHints, IMGPROXY_ENABLE_CLIENT_HINTS),
+
+		// URL processing
+		env.String(&c.ArgumentsSeparator, IMGPROXY_ARGUMENTS_SEPARATOR),
+		env.String(&c.BaseURL, IMGPROXY_BASE_URL),
+		env.Bool(&c.Base64URLIncludesFilename, IMGPROXY_BASE64_URL_INCLUDES_FILENAME),
+
+		env.StringSliceFile(&c.Presets, PRESETS_PATH, presetsPath),
+		env.URLReplacements(&c.URLReplacements, IMGPROXY_URL_REPLACEMENTS),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate validates the configuration values
 func (c *Config) Validate() error {
 	// Arguments separator validation
 	if c.ArgumentsSeparator == "" {
-		return errors.New("arguments separator cannot be empty")
+		return IMGPROXY_ARGUMENTS_SEPARATOR.ErrorEmpty()
 	}
 
 	return nil

+ 2 - 3
options/parser/processing_options_test.go

@@ -10,7 +10,6 @@ import (
 	"testing"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/options/keys"
@@ -93,7 +92,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithBase() {
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithReplacement() {
-	s.config().URLReplacements = []config.URLReplacement{
+	s.config().URLReplacements = []URLReplacement{
 		{Regexp: regexp.MustCompile("^test://([^/]*)/"), Replacement: "test2://images.dev/${1}/dolor/"},
 		{Regexp: regexp.MustCompile("^test2://"), Replacement: "http://"},
 	}
@@ -150,7 +149,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithBase() {
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithReplacement() {
-	s.config().URLReplacements = []config.URLReplacement{
+	s.config().URLReplacements = []URLReplacement{
 		{Regexp: regexp.MustCompile("^test://([^/]*)/"), Replacement: "test2://images.dev/${1}/dolor/"},
 		{Regexp: regexp.MustCompile("^test2://"), Replacement: "http://"},
 	}

+ 45 - 20
processing/config.go

@@ -2,15 +2,30 @@ package processing
 
 import (
 	"errors"
-	"fmt"
-	"log/slog"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
+var (
+	IMGPROXY_PREFERRED_FORMATS       = env.Describe("IMGPROXY_PREFERRED_FORMATS", "jpeg|png|gif|webp|avif|jxl|tiff|svg")
+	IMGPROXY_SKIP_PROCESSING_FORMATS = env.Describe("IMGPROXY_SKIP_PROCESSING_FORMATS", "jpeg|png|gif|webp|avif|jxl|tiff|svg")
+	IMGPROXY_WATERMARK_OPACITY       = env.Describe("IMGPROXY_WATERMARK_OPACITY", "number between 0..1")
+	IMGPROXY_DISABLE_SHRINK_ON_LOAD  = env.Describe("IMGPROXY_DISABLE_SHRINK_ON_LOAD", "boolean")
+	IMGPROXY_USE_LINEAR_COLORSPACE   = env.Describe("IMGPROXY_USE_LINEAR_COLORSPACE", "boolean")
+	IMGPROXY_SANITIZE_SVG            = env.Describe("IMGPROXY_SANITIZE_SVG", "boolean")
+	IMGPROXY_ALWAYS_RASTERIZE_SVG    = env.Describe("IMGPROXY_ALWAYS_RASTERIZE_SVG", "boolean")
+	IMGPROXY_QUALITY                 = env.Describe("IMGPROXY_QUALITY", "number between 0..100")
+	IMGPROXY_FORMAT_QUALITY          = env.Describe("IMGPROXY_FORMAT_QUALITY", "comma-separated list of format=quality pairs where quality is between 0..100")
+	IMGPROXY_STRIP_METADATA          = env.Describe("IMGPROXY_STRIP_METADATA", "boolean")
+	IMGPROXY_KEEP_COPYRIGHT          = env.Describe("IMGPROXY_KEEP_COPYRIGHT", "boolean")
+	IMGPROXY_STRIP_COLOR_PROFILE     = env.Describe("IMGPROXY_STRIP_COLOR_PROFILE", "boolean")
+	IMGPROXY_AUTO_ROTATE             = env.Describe("IMGPROXY_AUTO_ROTATE", "boolean")
+	IMGPROXY_ENFORCE_THUMBNAIL       = env.Describe("IMGPROXY_ENFORCE_THUMBNAIL", "boolean")
+)
+
 // Config holds pipeline-related configuration.
 type Config struct {
 	PreferredFormats      []imagetype.Type
@@ -57,39 +72,49 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.WatermarkOpacity = config.WatermarkOpacity
-	c.DisableShrinkOnLoad = config.DisableShrinkOnLoad
-	c.UseLinearColorspace = config.UseLinearColorspace
-	c.SkipProcessingFormats = config.SkipProcessingFormats
-	c.PreferredFormats = config.PreferredFormats
-	c.SanitizeSvg = config.SanitizeSvg
-	c.AlwaysRasterizeSvg = config.AlwaysRasterizeSvg
-	c.AutoRotate = config.AutoRotate
-	c.EnforceThumbnail = config.EnforceThumbnail
-
-	return c, nil
+	err := errors.Join(
+		env.Float(&c.WatermarkOpacity, IMGPROXY_WATERMARK_OPACITY),
+		env.Bool(&c.DisableShrinkOnLoad, IMGPROXY_DISABLE_SHRINK_ON_LOAD),
+		env.Bool(&c.UseLinearColorspace, IMGPROXY_USE_LINEAR_COLORSPACE),
+		env.Bool(&c.SanitizeSvg, IMGPROXY_SANITIZE_SVG),
+		env.Bool(&c.AlwaysRasterizeSvg, IMGPROXY_ALWAYS_RASTERIZE_SVG),
+		env.Int(&c.Quality, IMGPROXY_QUALITY),
+		env.ImageTypesQuality(c.FormatQuality, IMGPROXY_FORMAT_QUALITY),
+		env.Bool(&c.StripMetadata, IMGPROXY_STRIP_METADATA),
+		env.Bool(&c.KeepCopyright, IMGPROXY_KEEP_COPYRIGHT),
+		env.Bool(&c.StripColorProfile, IMGPROXY_STRIP_COLOR_PROFILE),
+		env.Bool(&c.AutoRotate, IMGPROXY_AUTO_ROTATE),
+		env.Bool(&c.EnforceThumbnail, IMGPROXY_ENFORCE_THUMBNAIL),
+
+		env.ImageTypes(&c.PreferredFormats, IMGPROXY_PREFERRED_FORMATS),
+		env.ImageTypes(&c.SkipProcessingFormats, IMGPROXY_SKIP_PROCESSING_FORMATS),
+	)
+
+	return c, err
 }
 
 // Validate checks if the configuration is valid
 func (c *Config) Validate() error {
-	if c.WatermarkOpacity <= 0 {
-		return errors.New("watermark opacity should be greater than 0")
-	} else if c.WatermarkOpacity > 1 {
-		return errors.New("watermark opacity should be less than or equal to 1")
+	if c.WatermarkOpacity <= 0 || c.WatermarkOpacity > 1 {
+		return IMGPROXY_WATERMARK_OPACITY.Errorf("must be between 0 and 1")
+	}
+
+	if c.Quality <= 0 || c.Quality > 100 {
+		return IMGPROXY_QUALITY.Errorf("must be between 0 and 100")
 	}
 
 	filtered := c.PreferredFormats[:0]
 
 	for _, t := range c.PreferredFormats {
 		if !vips.SupportsSave(t) {
-			slog.Warn(fmt.Sprintf("%s can't be a preferred format as it's saving is not supported", t))
+			IMGPROXY_PREFERRED_FORMATS.Warn("can't be a preferred format as it's saving is not supported", "format", t)
 		} else {
 			filtered = append(filtered, t)
 		}
 	}
 
 	if len(filtered) == 0 {
-		return errors.New("no supported preferred formats specified")
+		return IMGPROXY_PREFERRED_FORMATS.Errorf("no supported preferred formats specified")
 	}
 
 	c.PreferredFormats = filtered

+ 39 - 21
security/config.go

@@ -1,12 +1,27 @@
 package security
 
 import (
+	"errors"
 	"fmt"
-	"log/slog"
 	"regexp"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_ALLOW_SECURITY_OPTIONS = env.Describe("IMGPROXY_ALLOW_SECURITY_OPTIONS", "boolean")
+	IMGPROXY_ALLOWED_SOURCES        = env.Describe("IMGPROXY_ALLOWED_SOURCES", "comma-separated lists of regexes")
+	IMGPROXY_KEYS                   = env.Describe("IMGPROXY_KEYS", "comma-separated list of hex strings")
+	IMGPROXY_SALTS                  = env.Describe("IMGPROXY_SALTS", "comma-separated list of hex strings")
+	IMGPROXY_SIGNATURE_SIZE         = env.Describe("IMGPROXY_SIGNATURE_SIZE", "number between 1 and 32")
+	IMGPROXY_TRUSTED_SIGNATURES     = env.Describe("IMGPROXY_TRUSTED_SIGNATURES", "comma-separated list of strings")
+
+	IMGPROXY_MAX_SRC_RESOLUTION             = env.Describe("IMGPROXY_MAX_SRC_RESOLUTION", "number > 0")
+	IMGPROXY_MAX_SRC_FILE_SIZE              = env.Describe("IMGPROXY_MAX_SRC_FILE_SIZE", "number >= 0")
+	IMGPROXY_MAX_ANIMATION_FRAMES           = env.Describe("IMGPROXY_MAX_ANIMATION_FRAMES", "number > 0")
+	IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION = env.Describe("IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION", "number > 0")
+	IMGPROXY_MAX_RESULT_DIMENSION           = env.Describe("IMGPROXY_MAX_RESULT_DIMENSION", "number > 0")
 )
 
 // Config is the package-local configuration
@@ -24,7 +39,7 @@ type Config struct {
 func NewDefaultConfig() Config {
 	return Config{
 		DefaultOptions: Options{
-			MaxSrcResolution:            50000000,
+			MaxSrcResolution:            50_000_000,
 			MaxSrcFileSize:              0,
 			MaxAnimationFrames:          1,
 			MaxAnimationFrameResolution: 0,
@@ -39,34 +54,37 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.AllowSecurityOptions = config.AllowSecurityOptions
-	c.AllowedSources = config.AllowedSources
-	c.Keys = config.Keys
-	c.Salts = config.Salts
-	c.SignatureSize = config.SignatureSize
-	c.TrustedSignatures = config.TrustedSignatures
+	err := errors.Join(
+		env.Bool(&c.AllowSecurityOptions, IMGPROXY_ALLOW_SECURITY_OPTIONS),
+		env.Patterns(&c.AllowedSources, IMGPROXY_ALLOWED_SOURCES),
+		env.Int(&c.SignatureSize, IMGPROXY_SIGNATURE_SIZE),
+		env.StringSlice(&c.TrustedSignatures, IMGPROXY_TRUSTED_SIGNATURES),
+
+		env.MegaInt(&c.DefaultOptions.MaxSrcResolution, IMGPROXY_MAX_SRC_RESOLUTION),
+		env.Int(&c.DefaultOptions.MaxSrcFileSize, IMGPROXY_MAX_SRC_FILE_SIZE),
+		env.Int(&c.DefaultOptions.MaxAnimationFrames, IMGPROXY_MAX_ANIMATION_FRAMES),
+		env.MegaInt(&c.DefaultOptions.MaxAnimationFrameResolution, IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION),
+		env.Int(&c.DefaultOptions.MaxResultDimension, IMGPROXY_MAX_RESULT_DIMENSION),
 
-	c.DefaultOptions.MaxSrcResolution = config.MaxSrcResolution
-	c.DefaultOptions.MaxSrcFileSize = config.MaxSrcFileSize
-	c.DefaultOptions.MaxAnimationFrames = config.MaxAnimationFrames
-	c.DefaultOptions.MaxAnimationFrameResolution = config.MaxAnimationFrameResolution
-	c.DefaultOptions.MaxResultDimension = config.MaxResultDimension
+		env.HexSlice(&c.Keys, IMGPROXY_KEYS),
+		env.HexSlice(&c.Salts, IMGPROXY_SALTS),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate validates the configuration
 func (c *Config) Validate() error {
 	if c.DefaultOptions.MaxSrcResolution <= 0 {
-		return fmt.Errorf("max src resolution should be greater than 0, now - %d", c.DefaultOptions.MaxSrcResolution)
+		return IMGPROXY_MAX_SRC_RESOLUTION.ErrorZeroOrNegative()
 	}
 
 	if c.DefaultOptions.MaxSrcFileSize < 0 {
-		return fmt.Errorf("max src file size should be greater than or equal to 0, now - %d", c.DefaultOptions.MaxSrcFileSize)
+		return IMGPROXY_MAX_SRC_FILE_SIZE.ErrorNegative()
 	}
 
 	if c.DefaultOptions.MaxAnimationFrames <= 0 {
-		return fmt.Errorf("max animation frames should be greater than 0, now - %d", c.DefaultOptions.MaxAnimationFrames)
+		return IMGPROXY_MAX_ANIMATION_FRAMES.ErrorZeroOrNegative()
 	}
 
 	if len(c.Keys) != len(c.Salts) {
@@ -74,15 +92,15 @@ func (c *Config) Validate() error {
 	}
 
 	if len(c.Keys) == 0 {
-		slog.Warn("No keys defined, so signature checking is disabled")
+		IMGPROXY_KEYS.Warn("No keys defined, signature checking is disabled")
 	}
 
 	if len(c.Salts) == 0 {
-		slog.Warn("No salts defined, so signature checking is disabled")
+		IMGPROXY_SALTS.Warn("No salts defined, signature checking is disabled")
 	}
 
 	if c.SignatureSize < 1 || c.SignatureSize > 32 {
-		return fmt.Errorf("signature size should be within 1 and 32, now - %d", c.SignatureSize)
+		return IMGPROXY_SIGNATURE_SIZE.Errorf("invalid size")
 	}
 
 	return nil

+ 63 - 29
server/config.go

@@ -3,24 +3,42 @@ package server
 import (
 	"errors"
 	"fmt"
-	"os"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/server/responsewriter"
 )
 
+var (
+	IMGPROXY_PORT                    = env.Describe("IMGPROXY_PORT", "port")
+	IMGPROXY_NETWORK                 = env.Describe("IMGPROXY_NETWORK", "tcp|tcp4|tcp6|udp|udp4|udp6|unix|unixgram|unixpacket")
+	IMGPROXY_BIND                    = env.Describe("IMGPROXY_BIND", "address:port, path to unix socket, etc")
+	IMGPROXY_PATH_PREFIX             = env.Describe("IMGPROXY_PATH_PREFIX", "string")
+	IMGPROXY_MAX_CLIENTS             = env.Describe("IMGPROXY_MAX_CLIENTS", "number, 0 means unlimited")
+	IMGPROXY_TIMEOUT                 = env.Describe("IMGPROXY_TIMEOUT", "seconds > 0")
+	IMGPROXY_READ_REQUEST_TIMEOUT    = env.Describe("IMGPROXY_READ_REQUEST_TIMEOUT", "seconds > 0")
+	IMGPROXY_KEEP_ALIVE_TIMEOUT      = env.Describe("IMGPROXY_KEEP_ALIVE_TIMEOUT", "seconds >= 0")
+	IMGPROXY_GRACEFUL_STOP_TIMEOUT   = env.Describe("IMGPROXY_GRACEFUL_STOP_TIMEOUT", "seconds >= 0")
+	IMGPROXY_ALLOW_ORIGIN            = env.Describe("IMGPROXY_ALLOW_ORIGIN", "string")
+	IMGPROXY_SECRET                  = env.Describe("IMGPROXY_SECRET", "string")
+	IMGPROXY_DEVELOPMENT_ERRORS_MODE = env.Describe("IMGPROXY_DEVELOPMENT_ERRORS_MODE", "boolean")
+	IMGPROXY_SO_REUSEPORT            = env.Describe("IMGPROXY_SO_REUSEPORT", "boolean")
+	IMGPROXY_HEALTH_CHECK_PATH       = env.Describe("IMGPROXY_HEALTH_CHECK_PATH", "string")
+	IMGPROXY_FREE_MEMORY_INTERVAL    = env.Describe("IMGPROXY_FREE_MEMORY_INTERVAL", "seconds >= 0")
+	IMGPROXY_LOG_MEM_STATS           = env.Describe("IMGPROXY_LOG_MEM_STATS", "boolean")
+)
+
 // Config represents HTTP server config
 type Config struct {
-	Listen                string        // Address to listen on
 	Network               string        // Network type (tcp, unix)
 	Bind                  string        // Bind address
 	PathPrefix            string        // Path prefix for the server
 	MaxClients            int           // Maximum number of concurrent clients
+	RequestTimeout        time.Duration // Timeout for requests
 	ReadRequestTimeout    time.Duration // Timeout for reading requests
 	KeepAliveTimeout      time.Duration // Timeout for keep-alive connections
-	GracefulTimeout       time.Duration // Timeout for graceful shutdown
+	GracefulStopTimeout   time.Duration // Timeout for graceful shutdown
 	CORSAllowOrigin       string        // CORS allowed origin
 	Secret                string        // Secret for authorization
 	DevelopmentErrorsMode bool          // Enable development mode for detailed error messages
@@ -41,9 +59,10 @@ func NewDefaultConfig() Config {
 		Bind:                  ":8080",
 		PathPrefix:            "",
 		MaxClients:            2048,
+		RequestTimeout:        10 * time.Second,
 		ReadRequestTimeout:    10 * time.Second,
 		KeepAliveTimeout:      10 * time.Second,
-		GracefulTimeout:       20 * time.Second,
+		GracefulStopTimeout:   20 * time.Second,
 		CORSAllowOrigin:       "",
 		Secret:                "",
 		DevelopmentErrorsMode: false,
@@ -60,52 +79,67 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.Network = config.Network
-	c.Bind = config.Bind
-	c.PathPrefix = config.PathPrefix
-	c.MaxClients = config.MaxClients
-	c.ReadRequestTimeout = time.Duration(config.ReadRequestTimeout) * time.Second
-	c.KeepAliveTimeout = time.Duration(config.KeepAliveTimeout) * time.Second
-	c.GracefulTimeout = time.Duration(config.GracefulStopTimeout) * time.Second
-	c.CORSAllowOrigin = config.AllowOrigin
-	c.Secret = config.Secret
-	c.DevelopmentErrorsMode = config.DevelopmentErrorsMode
-	c.SocketReusePort = config.SoReuseport
-	c.HealthCheckPath = config.HealthCheckPath
-	c.FreeMemoryInterval = time.Duration(config.FreeMemoryInterval) * time.Second
-	c.LogMemStats = len(os.Getenv("IMGPROXY_LOG_MEM_STATS")) > 0
-
-	if _, err := responsewriter.LoadConfigFromEnv(&c.ResponseWriter); err != nil {
+	var port string
+	if err := env.String(&port, IMGPROXY_PORT); err != nil {
 		return nil, err
 	}
 
-	return c, nil
+	if len(port) > 0 {
+		c.Bind = fmt.Sprintf(":%s", port)
+	}
+
+	_, rwErr := responsewriter.LoadConfigFromEnv(&c.ResponseWriter)
+
+	err := errors.Join(
+		rwErr,
+		env.String(&c.Network, IMGPROXY_NETWORK),
+		env.String(&c.Bind, IMGPROXY_BIND),
+		env.URLPath(&c.PathPrefix, IMGPROXY_PATH_PREFIX),
+		env.Int(&c.MaxClients, IMGPROXY_MAX_CLIENTS),
+		env.Duration(&c.RequestTimeout, IMGPROXY_TIMEOUT),
+		env.Duration(&c.ReadRequestTimeout, IMGPROXY_READ_REQUEST_TIMEOUT),
+		env.Duration(&c.KeepAliveTimeout, IMGPROXY_KEEP_ALIVE_TIMEOUT),
+		env.Duration(&c.GracefulStopTimeout, IMGPROXY_GRACEFUL_STOP_TIMEOUT),
+		env.String(&c.CORSAllowOrigin, IMGPROXY_ALLOW_ORIGIN),
+		env.String(&c.Secret, IMGPROXY_SECRET),
+		env.Bool(&c.DevelopmentErrorsMode, IMGPROXY_DEVELOPMENT_ERRORS_MODE),
+		env.Bool(&c.SocketReusePort, IMGPROXY_SO_REUSEPORT),
+		env.URLPath(&c.HealthCheckPath, IMGPROXY_HEALTH_CHECK_PATH),
+		env.Duration(&c.FreeMemoryInterval, IMGPROXY_FREE_MEMORY_INTERVAL),
+		env.Bool(&c.LogMemStats, IMGPROXY_LOG_MEM_STATS),
+	)
+
+	return c, err
 }
 
 // Validate checks that the config values are valid
 func (c *Config) Validate() error {
 	if len(c.Bind) == 0 {
-		return errors.New("bind address is not defined")
+		return IMGPROXY_BIND.ErrorEmpty()
 	}
 
 	if c.MaxClients < 0 {
-		return fmt.Errorf("max clients number should be greater than or equal 0, now - %d", c.MaxClients)
+		return IMGPROXY_MAX_CLIENTS.ErrorNegative()
+	}
+
+	if c.RequestTimeout <= 0 {
+		return IMGPROXY_TIMEOUT.ErrorZeroOrNegative()
 	}
 
 	if c.ReadRequestTimeout <= 0 {
-		return fmt.Errorf("read request timeout should be greater than 0, now - %d", c.ReadRequestTimeout)
+		return IMGPROXY_READ_REQUEST_TIMEOUT.ErrorZeroOrNegative()
 	}
 
 	if c.KeepAliveTimeout < 0 {
-		return fmt.Errorf("keep alive timeout should be greater than or equal to 0, now - %d", c.KeepAliveTimeout)
+		return IMGPROXY_KEEP_ALIVE_TIMEOUT.ErrorNegative()
 	}
 
-	if c.GracefulTimeout < 0 {
-		return fmt.Errorf("graceful timeout should be greater than or equal to 0, now - %d", c.GracefulTimeout)
+	if c.GracefulStopTimeout < 0 {
+		return IMGPROXY_GRACEFUL_STOP_TIMEOUT.ErrorNegative()
 	}
 
 	if c.FreeMemoryInterval <= 0 {
-		return errors.New("free memory interval should be greater than zero")
+		return IMGPROXY_FREE_MEMORY_INTERVAL.ErrorZeroOrNegative()
 	}
 
 	return nil

+ 25 - 10
server/responsewriter/config.go

@@ -1,12 +1,21 @@
 package responsewriter
 
 import (
-	"fmt"
+	"errors"
 	"strings"
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_SET_CANONICAL_HEADER      = env.Describe("IMGPROXY_SET_CANONICAL_HEADER", "boolean")
+	IMGPROXY_TTL                       = env.Describe("IMGPROXY_TTL", "seconds >= 0")
+	IMGPROXY_FALLBACK_IMAGE_TTL        = env.Describe("IMGPROXY_FALLBACK_IMAGE_TTL", "seconds >= 0")
+	IMGPROXY_CACHE_CONTROL_PASSTHROUGH = env.Describe("IMGPROXY_CACHE_CONTROL_PASSTHROUGH", "boolean")
+	IMGPROXY_WRITE_RESPONSE_TIMEOUT    = env.Describe("IMGPROXY_WRITE_RESPONSE_TIMEOUT", "seconds > 0")
 )
 
 // Config holds configuration for response writer
@@ -23,7 +32,7 @@ type Config struct {
 func NewDefaultConfig() Config {
 	return Config{
 		SetCanonicalHeader:      false,
-		DefaultTTL:              31536000,
+		DefaultTTL:              31_536_000,
 		FallbackImageTTL:        0,
 		CacheControlPassthrough: false,
 		VaryValue:               "",
@@ -35,11 +44,16 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.SetCanonicalHeader = config.SetCanonicalHeader
-	c.DefaultTTL = config.TTL
-	c.FallbackImageTTL = config.FallbackImageTTL
-	c.CacheControlPassthrough = config.CacheControlPassthrough
-	c.WriteResponseTimeout = time.Duration(config.WriteResponseTimeout) * time.Second
+	err := errors.Join(
+		env.Bool(&c.SetCanonicalHeader, IMGPROXY_SET_CANONICAL_HEADER),
+		env.Int(&c.DefaultTTL, IMGPROXY_TTL),
+		env.Int(&c.FallbackImageTTL, IMGPROXY_FALLBACK_IMAGE_TTL),
+		env.Bool(&c.CacheControlPassthrough, IMGPROXY_CACHE_CONTROL_PASSTHROUGH),
+		env.Duration(&c.WriteResponseTimeout, IMGPROXY_WRITE_RESPONSE_TIMEOUT),
+	)
+	if err != nil {
+		return nil, err
+	}
 
 	vary := make([]string, 0)
 
@@ -56,6 +70,7 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 	return c, nil
 }
 
+// TODO: REMOVE REFERENCE TO GLOBAL CONFIG
 func (c *Config) envEnableFormatDetection() bool {
 	return config.AutoWebp ||
 		config.EnforceWebp ||
@@ -72,15 +87,15 @@ func (c *Config) envEnableClientHints() bool {
 // Validate checks config for errors
 func (c *Config) Validate() error {
 	if c.DefaultTTL < 0 {
-		return fmt.Errorf("image TTL should be greater than or equal to 0, now - %d", c.DefaultTTL)
+		return IMGPROXY_TTL.ErrorNegative()
 	}
 
 	if c.FallbackImageTTL < 0 {
-		return fmt.Errorf("fallback image TTL should be greater than or equal to 0, now - %d", c.FallbackImageTTL)
+		return IMGPROXY_FALLBACK_IMAGE_TTL.ErrorNegative()
 	}
 
 	if c.WriteResponseTimeout <= 0 {
-		return fmt.Errorf("write response timeout should be greater than 0, now - %d", c.WriteResponseTimeout)
+		return IMGPROXY_WRITE_RESPONSE_TIMEOUT.ErrorZeroOrNegative()
 	}
 
 	return nil

+ 1 - 1
server/router.go

@@ -125,7 +125,7 @@ func (r *Router) HEAD(path string, handler RouteHandler, middlewares ...Middlewa
 // ServeHTTP serves routes
 func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 	// Attach timer to the context
-	req, timeoutCancel := startRequestTimer(req)
+	req, timeoutCancel := startRequestTimer(req, r.config.RequestTimeout)
 	defer timeoutCancel()
 
 	// Create the [ResponseWriter]

+ 2 - 3
server/server.go

@@ -9,7 +9,6 @@ import (
 
 	"golang.org/x/net/netutil"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/reuseport"
 )
 
@@ -52,7 +51,7 @@ func Start(cancel context.CancelFunc, router *Router) (*Server, error) {
 		ErrorLog:       errLogger,
 	}
 
-	if config.KeepAliveTimeout > 0 {
+	if router.config.KeepAliveTimeout > 0 {
 		srv.IdleTimeout = router.config.KeepAliveTimeout
 	} else {
 		srv.SetKeepAlivesEnabled(false)
@@ -79,7 +78,7 @@ func Start(cancel context.CancelFunc, router *Router) (*Server, error) {
 func (s *Server) Shutdown(ctx context.Context) {
 	slog.Info("Shutting down the server...")
 
-	ctx, close := context.WithTimeout(ctx, s.router.config.GracefulTimeout)
+	ctx, close := context.WithTimeout(ctx, s.router.config.GracefulStopTimeout)
 	defer close()
 
 	s.server.Shutdown(ctx)

+ 2 - 3
server/timer.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
@@ -15,10 +14,10 @@ import (
 type timerSinceCtxKey struct{}
 
 // startRequestTimer starts a new request timer.
-func startRequestTimer(r *http.Request) (*http.Request, context.CancelFunc) {
+func startRequestTimer(r *http.Request, timeout time.Duration) (*http.Request, context.CancelFunc) {
 	ctx := r.Context()
 	ctx = context.WithValue(ctx, timerSinceCtxKey{}, time.Now())
-	ctx, cancel := context.WithTimeout(ctx, time.Duration(config.Timeout)*time.Second)
+	ctx, cancel := context.WithTimeout(ctx, timeout)
 	return r.WithContext(ctx), cancel
 }
 

+ 2 - 2
server/timer_test.go

@@ -25,7 +25,7 @@ func TestCheckTimeout(t *testing.T) {
 			name: "ActiveTimerContext",
 			setup: func() context.Context {
 				req := httptest.NewRequest(http.MethodGet, "/test", nil)
-				newReq, _ := startRequestTimer(req)
+				newReq, _ := startRequestTimer(req, 10*time.Second)
 				return newReq.Context()
 			},
 			fail: false,
@@ -34,7 +34,7 @@ func TestCheckTimeout(t *testing.T) {
 			name: "CancelledContext",
 			setup: func() context.Context {
 				req := httptest.NewRequest(http.MethodGet, "/test", nil)
-				newReq, cancel := startRequestTimer(req)
+				newReq, cancel := startRequestTimer(req, 10*time.Second)
 				cancel() // Cancel immediately
 				return newReq.Context()
 			},

+ 49 - 27
vips/config.go

@@ -5,11 +5,25 @@ package vips
 */
 import "C"
 import (
-	"fmt"
-	"os"
+	"errors"
 
-	globalConfig "github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_JPEG_PROGRESSIVE        = env.Describe("IMGPROXY_JPEG_PROGRESSIVE", "boolean")
+	IMGPROXY_PNG_INTERLACED          = env.Describe("IMGPROXY_PNG_INTERLACED", "boolean")
+	IMGPROXY_PNG_QUANTIZE            = env.Describe("IMGPROXY_PNG_QUANTIZE", "boolean")
+	IMGPROXY_PNG_QUANTIZATION_COLORS = env.Describe("IMGPROXY_PNG_QUANTIZATION_COLORS", "number between 2 and 256")
+	IMGPROXY_WEBP_PRESET             = env.Describe("IMGPROXY_WEBP_PRESET", "default|picture|photo|drawing|icon|text")
+	IMGPROXY_AVIF_SPEED              = env.Describe("IMGPROXY_AVIF_SPEED", "number between 0 and 9")
+	IMGPROXY_WEBP_EFFORT             = env.Describe("IMGPROXY_WEBP_EFFORT", "number between 1 and 6")
+	IMGPROXY_JXL_EFFORT              = env.Describe("IMGPROXY_JXL_EFFORT", "number between 1 and 9")
+	IMGPROXY_PNG_UNLIMITED           = env.Describe("IMGPROXY_PNG_UNLIMITED", "boolean")
+	IMGPROXY_SVG_UNLIMITED           = env.Describe("IMGPROXY_SVG_UNLIMITED", "boolean")
+	IMGPROXY_VIPS_LEAK_CHECK         = env.Describe("IMGPROXY_VIPS_LEAK_CHECK", "boolean")
+	IMGPROXY_VIPS_CACHE_TRACE        = env.Describe("IMGPROXY_VIPS_CACHE_TRACE", "boolean")
 )
 
 type Config struct {
@@ -69,53 +83,61 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.JpegProgressive = globalConfig.JpegProgressive
-
-	c.PngInterlaced = globalConfig.PngInterlaced
-	c.PngQuantize = globalConfig.PngQuantize
-	c.PngQuantizationColors = globalConfig.PngQuantizationColors
+	var leakCheck, cacheTrace string
+
+	// default preset so parsing below won't fail on empty value
+	webpPreset := c.WebpPreset.String()
+
+	err := errors.Join(
+		env.Bool(&c.JpegProgressive, IMGPROXY_JPEG_PROGRESSIVE),
+		env.Bool(&c.PngInterlaced, IMGPROXY_PNG_INTERLACED),
+		env.Bool(&c.PngQuantize, IMGPROXY_PNG_QUANTIZE),
+		env.Int(&c.PngQuantizationColors, IMGPROXY_PNG_QUANTIZATION_COLORS),
+		env.Int(&c.AvifSpeed, IMGPROXY_AVIF_SPEED),
+		env.Int(&c.WebpEffort, IMGPROXY_WEBP_EFFORT),
+		env.Int(&c.JxlEffort, IMGPROXY_JXL_EFFORT),
+		env.Bool(&c.PngUnlimited, IMGPROXY_PNG_UNLIMITED),
+		env.Bool(&c.SvgUnlimited, IMGPROXY_SVG_UNLIMITED),
+
+		env.String(&webpPreset, IMGPROXY_WEBP_PRESET),
+		env.String(&leakCheck, IMGPROXY_VIPS_LEAK_CHECK),
+		env.String(&cacheTrace, IMGPROXY_VIPS_CACHE_TRACE),
+	)
+	if err != nil {
+		return nil, err
+	}
 
-	if pr, ok := WebpPresets[globalConfig.WebpPreset]; ok {
+	if pr, ok := WebpPresets[webpPreset]; ok {
 		c.WebpPreset = pr
 	} else {
-		return nil, fmt.Errorf("invalid WebP preset: %s", globalConfig.WebpPreset)
+		return nil, IMGPROXY_WEBP_PRESET.Errorf("invalid WebP preset: %s", webpPreset)
 	}
 
-	c.AvifSpeed = globalConfig.AvifSpeed
-	c.WebpEffort = globalConfig.WebpEffort
-	c.JxlEffort = globalConfig.JxlEffort
-
-	c.PngUnlimited = globalConfig.PngUnlimited
-	c.SvgUnlimited = globalConfig.SvgUnlimited
-
-	c.LeakCheck = len(os.Getenv("IMGPROXY_VIPS_LEAK_CHECK")) > 0
-	c.CacheTrace = len(os.Getenv("IMGPROXY_VIPS_CACHE_TRACE")) > 0
+	c.LeakCheck = len(leakCheck) > 0
+	c.CacheTrace = len(cacheTrace) > 0
 
 	return c, nil
 }
 
 func (c *Config) Validate() error {
 	if c.PngQuantizationColors < 2 || c.PngQuantizationColors > 256 {
-		return fmt.Errorf(
-			"IMGPROXY_PNG_QUANTIZATION_COLORS must be between 2 and 256, got %d",
-			c.PngQuantizationColors,
-		)
+		return IMGPROXY_PNG_QUANTIZATION_COLORS.ErrorRange()
 	}
 
 	if c.WebpPreset < C.VIPS_FOREIGN_WEBP_PRESET_DEFAULT || c.WebpPreset >= C.VIPS_FOREIGN_WEBP_PRESET_LAST {
-		return fmt.Errorf("invalid IMGPROXY_WEBP_PRESET: %d", c.WebpPreset)
+		return IMGPROXY_WEBP_PRESET.ErrorRange()
 	}
 
 	if c.AvifSpeed < 0 || c.AvifSpeed > 9 {
-		return fmt.Errorf("IMGPROXY_AVIF_SPEED must be between 0 and 9, got %d", c.AvifSpeed)
+		return IMGPROXY_AVIF_SPEED.ErrorRange()
 	}
 
 	if c.JxlEffort < 1 || c.JxlEffort > 9 {
-		return fmt.Errorf("IMGPROXY_JXL_EFFORT must be between 1 and 9, got %d", c.JxlEffort)
+		return IMGPROXY_JXL_EFFORT.ErrorRange()
 	}
 
 	if c.WebpEffort < 1 || c.WebpEffort > 6 {
-		return fmt.Errorf("IMGPROXY_WEBP_EFFORT must be between 1 and 6, got %d", c.WebpEffort)
+		return IMGPROXY_WEBP_EFFORT.ErrorRange()
 	}
 
 	return nil

+ 14 - 7
workers/config.go

@@ -1,11 +1,16 @@
 package workers
 
 import (
-	"fmt"
+	"errors"
 	"runtime"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_REQUESTS_QUEUE_SIZE = env.Describe("IMGPROXY_REQUESTS_QUEUE_SIZE", "number > 0")
+	IMGPROXY_WORKERS_NUMBER      = env.Describe("IMGPROXY_WORKERS_NUMBER", "number > 0")
 )
 
 // Config represents [Workers] config
@@ -26,20 +31,22 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	c.RequestsQueueSize = config.RequestsQueueSize
-	c.WorkersNumber = config.Workers
+	err := errors.Join(
+		env.Int(&c.RequestsQueueSize, IMGPROXY_REQUESTS_QUEUE_SIZE),
+		env.Int(&c.WorkersNumber, IMGPROXY_WORKERS_NUMBER),
+	)
 
-	return c, nil
+	return c, err
 }
 
 // Validate checks configuration values
 func (c *Config) Validate() error {
 	if c.RequestsQueueSize < 0 {
-		return fmt.Errorf("requests queue size should be greater than or equal 0, now - %d", c.RequestsQueueSize)
+		return IMGPROXY_REQUESTS_QUEUE_SIZE.ErrorNegative()
 	}
 
 	if c.WorkersNumber <= 0 {
-		return fmt.Errorf("workers number should be greater than 0, now - %d", c.WorkersNumber)
+		return IMGPROXY_WORKERS_NUMBER.ErrorZeroOrNegative()
 	}
 
 	return nil